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

@streaming-cdn/rtc-web

v1.9.3

Published

The customer package contains compiled ESM/UMD JavaScript and TypeScript declarations. It intentionally excludes implementation source and source maps. Example source remains available under `examples/`.

Readme

RTC Web SDK 1.9.3

The customer package contains compiled ESM/UMD JavaScript and TypeScript declarations. It intentionally excludes implementation source and source maps. Example source remains available under examples/.

Install the package from the extracted archive or publish it to your private npm registry. Verify the archive against SHA256SUMS.txt; a Server API key must never be placed in browser code.

Integration modes

| Mode | Use | Required APIs | | --- | --- | --- | | Media only | Your app already owns dialing, push, and UI | createRtcClient() | | Managed signaling, custom UI | Online presence and call events, rendered by your app | createRtcIncomingClient() without mount | | Custom signaling or push, SDK call UI | Your delivery channel injects calls into the SDK | autoConnect: false, handleIncomingCall() | | Managed signaling and SDK call UI | Fastest complete integration | signalingUrl plus mount |

Media only

Your backend creates or accepts the call and returns only the participant credential.

import { createRtcClient } from "@streaming-cdn/rtc-web";

const media = await createRtcClient({
  mode: "video",
  credential: response.credential,
  autoJoin: true,
  quality: "auto",
  preferredVideoCodec: "h264"
});

// Bind tracks from the native client to any DOM/framework renderer.
const nativeClient = media.getNativeClient();

No iframe, push adapter, incoming UI, or signaling client is created.

For a receive-only device, disable local capture without disabling remote media negotiation:

const media = await createRtcClient({
  mode: "video", credential: response.credential,
  audio: false, video: false,
  receiveAudio: true, receiveVideo: true
});
media.addEventListener("remotestream", ({ detail }) => {
  const { participant, stream, track } = detail.detail;
  attachRemoteStream(participant, stream, track);
});
await media.join();

localstream carries { stream }; remotestream carries { participant, stream, track }. Register both listeners before join(). remotestream means the peer's description carried this track — the negotiation step remote-description-set — and nothing more: the browser raises the track event while applying the remote description, before ICE has started, so no data is flowing yet. Bind the stream to your element on it; call the call "connected" on mediaconnected (below), never on this.

Managed signaling with custom UI

const phone = createRtcIncomingClient({
  tokenProvider: async () => {
    const response = await fetch("/api/rtc/client-token", { credentials: "include" });
    if (!response.ok) throw new Error("Unable to refresh RTC identity");
    return response.json(); // { token, expiresAt }
  },
  signalingUrl,
  onEvent(event) {
    if (event.type === "presence") renderOnlineUsers(event.detail.users);
    if (event.type === "incomingcall") showYourIncomingUi(event.detail);
    if (event.type === "callupdated") updateYourCallUi(event.detail);
  }
});

const accepted = await phone.accept(callId);
const media = await createRtcClient({ mode: "video", credential: accepted.credential!, autoJoin: true });
phone.attachMediaClient(callId, media);

Custom push or signaling with SDK UI

const phone = createRtcIncomingClient({
  tokenProvider: async () => {
    const response = await fetch("/api/rtc/client-token", { credentials: "include" });
    if (!response.ok) throw new Error("Unable to refresh RTC identity");
    return response.json();
  },
  callActionsUrl: "https://streaming-cdn.ewin888.com",
  autoConnect: false,
  mount: document.querySelector("#incoming-call")
});

yourPush.onMessage((payload) => phone.handleIncomingCall({
  id: payload.callId,
  mode: payload.mode,
  status: "ringing",
  caller: { id: payload.callerId, name: payload.callerName }
}));

Omit mount when your application also owns the incoming-call UI. Call accept, reject, busy, end, or cancel from your own controls.

Where call actions go: callActionsUrl

Accept, reject, busy, end and cancel are not local actions: the call's state lives in a record on the platform, and pressing Accept must (1) update that record, (2) notify the caller, and (3) stop the ringing on the callee's other devices. The button therefore sends one authenticated HTTP request — and it cannot ride the signaling socket, because when a push notification wakes the app there is no socket yet. callActionsUrl is the origin those requests go to. It applies ONLY to the incoming-call client's call actions; no other SDK API reads it.

Caller                     Platform                          Callee device
  |  create call (server API) |                                    |
  |-------------------------->|  call record: "ringing"            |
  |      ringback tone        |---- push / signaling notify ------>|  incoming-call UI shows
  |                           |                                    |  (no connection exists yet)
  |                           |                                    |  user taps Accept / Reject
  |                           |<== POST {callActionsUrl}/v1/rtc/client/calls/:id/accept
  |                           |    the server arbitrates: accepted,
  |                           |    or "already cancelled" if the
  |                           |    caller hung up first
  |<-- "accepted" notify -----|--- stop ringing other devices ---->|
  |                           |                                    |  connect signaling (signalingUrl)
  |<================== media session establishes ================>|

Resolution order: callActionsUrl -> signalingUrl's origin -> the page's own origin. In practice:

| Scenario | Needed? | | --- | --- | | Web app served from the platform's own domain | No — the page origin is used | | Your own website (different domain) embedding the incoming-call UI | Yes — set it to the platform origin | | signalingUrl already provided | No — its origin is reused | | React Native / non-browser | Provide signalingUrl or callActionsUrl (there is no page origin to fall back to) |

apiBaseUrl is the deprecated former name; it is still accepted as an alias so existing integrations keep working.

For outgoing UI, show contacting while mobile push is being delivered, move to ringing on call.delivery, and show connected only after call.updated: accepted and the media client's mediaconnected event. The media client's connected event means the signaling room was joined — it says nothing about media; a call marked connected on it can have zero media flowing. Do not call join() before acceptance. Cancel unanswered calls at the invitation expiresAt timestamp (45 seconds by default).

Connect phase: what fires when, and how long to wait

join() resolves when the signaling room accepts this participant. Media comes up afterwards, and the media client narrates every step of it:

| Event | Detail | Meaning | | --- | --- | --- | | connected | { participantId, resumed, elapsedMs } | Signaling room joined, elapsedMs after join() was called. Not media. | | participantjoined | participant, plus initial: true for peers that were already in the room when you joined | The other side is in the room. The first one restarts the connectTimeoutMs budget (see below); anchor your own budget here too. | | negotiation | { participantId, phase, at }at is ms since join() | One negotiation step: offer-created, offer-sent, offer-received, answer-created, answer-sent, answer-received, remote-description-set, ice-gathering-complete, ice-candidate-sent, ice-candidate-received. Never SDP or candidate bodies. | | icestate | { participantId, state } | iceConnectionState changed (peerconnectionstate carries connectionState). | | mediaconnected | { participantId, elapsedMs, first, transportConnected, inboundBytes, inboundPackets } | Media is flowing with this peer: its transport is established (connectionState connected, or iceConnectionState connected / completed) and, when the peer sends you anything, its data has arrived on that connection (bytesReceived or packetsReceived above zero in the connection's inbound stats; a remote track's unmute counts as well, but only once the transport is up). A peer that sends nothing (a viewer on a recvonly link) counts on the transport alone. Once per peer; first is true for the first peer of the session. This is "call connected". It is not the track / remotestream event, which fires with ICE still new. | | remotetrackactive | { participantId, kind: "audio" \| "video" } | This peer's inbound data of this kind arrived for the first time. Once per peer and kind. | | connecttimeout | { elapsedMs, stage, joined, waitedForPeerMs, sinceFirstPeerMs, peers: [{ participantId, connectionState, iceState, signalingState, transportConnected, inboundBytes, inboundPackets, lastNegotiationPhase }], participantCount } | connectTimeoutMs (default 30 000; 0 disables) elapsed with no mediaconnected, counted from the later of join() and the first remote participant's arrival. stage is waiting_for_peer (nobody else has been in the room since join(); waitedForPeerMs and sinceFirstPeerMs are null) or negotiating (somebody has; waitedForPeerMs is how long you waited for them after join(), sinceFirstPeerMs how long since). joined: false means this client never made it into the room — a local problem such as the permission prompt, with participantCount: 0 and peers: [] — read that before reading the peer list. elapsedMs is always since join(). At most once per stage: a peer that arrives after a waiting_for_peer timeout gets a fresh budget and, if its media never comes up either, its own negotiating timeout. The client does not leave — decide that yourself. | | peerwaittimeout | { waitedMs } | peerWaitTimeoutMs (default 0 = off) elapsed after join() and no remote participant has joined the room. Once. Reports only; how long to wait for the other side is your product's decision (a ring can take 45 s). | | signalingerror | { code: "send_dropped", messageType } | A signaling frame was dropped because the socket was not open. Reconnect rebuilds every peer; the loss is reported, never thrown. | | peerreconnecting | { participantId, reason: "failed" \| "disconnected", attempt } | A peer's connection lost its transport while both sides are still in the room. The client restarts ICE (the deterministic offerer re-offers with iceRestart; the other side answers). The peer stays in getParticipants(); participantleft fires only when a peer actually leaves (rtc.peer.left). disconnected is given 5 s to heal before it is treated like failed. If the connection fails while an offer is still unanswered, the restart offer is sent as soon as that answer arrives. Both sides must run this version; an older peer that is the offerer does not re-offer. On the SFU transport participantId is null and the event names the uplink, which is rebuilt rather than ICE-restarted (see Transports). |

A join that has not received the room roster within 15 s rejects and closes its socket; nothing on that socket can join the room afterwards.

A signaling reconnect on the mesh (reconnecting, then connected with resumed: true) rebuilds every peer link from the roster but does not start the connect phase over: mediaconnected and connecttimeout do not fire again for the rebuilt links, getConnectPhase() keeps the original join's values, and the roster is replayed as participantjoined with initial: true for every peer still in the room — a peer that left during the outage gets no participantleft. The React Native adapter (1.7.0) differs here: it restarts the phase for the rebuilt links and de-duplicates the replay. Key your UI on participantId rather than on the count of participantjoined events if it must survive a reconnect on both platforms.

On the SFU transport, negotiation is with the platform SFU, so negotiation, icestate and peerconnectionstate are attributed to your own participant id. mediaconnected fires for your own id once the SFU connection is up (that is the first one; your uplink receives nothing of its own), then per remote participant once their data arrives through it — remotetrackactive per kind, attributed by the track's mid.

getConnectPhase() returns { joinedAt, mediaConnected, mediaConnectedElapsedMs, timedOut, lastConnectTimeout, steps } for code that attaches late. getConnectionDiagnostics() returns the connecttimeout payload as it stands right now — stage, joined, every peer's transportConnected / inboundBytes / inboundPackets — plus mediaConnected, mediaConnectedAfterMs, mediaConnectedParticipantId, connectTimeoutFired, lastConnectTimeout and steps, the same shape as the React Native client's method of the same name. steps is where the time inside join() went, in ms since the call: localStreamMs (local media acquired), audioRouteMs (always null in a browser; the React Native client routes call audio natively) and connectedMs (the room accepted you).

The staged connect budget

Measured on the platform (2026-09): a call that connects does so within a few seconds of both sides being in the room, but getting both sides into the room takes as long as the slower device's ring, wake and accept. The budget is therefore staged, and the timer for negotiation starts when the other side is in the room (participantCount === 2 in stats, or participantjoined), not at join(). Since 1.9.1 connectTimeoutMs itself counts from that moment (the later of join() and the first remote participant's arrival), so the built-in timeout no longer spends its budget on the other side's ring:

| Stage | Budget | Signal that ends it | | --- | --- | --- | | Join the signaling room | 20 s | connected (or join() rejects) | | Wait for the other side to arrive | at least 20 s, without hanging up | participantjoined / participantCount === 2 | | Negotiation | 15 s from that moment | mediaconnected | | Give up | no earlier than 45 s after join() | connecttimeout, peerwaittimeout, or your own timer |

A callee who joined first spends the "wait for the other side" stage watching the caller's accepted notification travel; a caller whose callee was woken by push spends it watching the ring. Neither is a negotiation failure. connectTimeoutMs (default 30 000, counted from the other side's arrival) covers the negotiation stage; peerWaitTimeoutMs (off by default) covers the wait for the other side if your product wants a bound on it. Treat both as the diagnostics they are. Read connecttimeout in this order: joined: false — this client never entered the room (permission prompt, socket), a local problem; stage: "waiting_for_peer" — you were alone the whole time; otherwise stage: "negotiating" and the peers snapshot says whether the other side never answered (lastNegotiationPhase: "offer-sent"), exchanged descriptions and never found a route (transportConnected: false, iceState: "checking" after remote-description-set), or connected a transport that carried nothing (transportConnected: true, inboundPackets: 0).

The incoming-call client follows the same rule: attachMediaClient() no longer disarms its call watchdog. The watchdog is disarmed by the media client's mediaconnected; if that never comes, callexpired fires and the call is released, media client included — and, for a call this device accepted or placed, the client also sends the end action, so the other side's call client receives call.updated: ended and the platform's call record does not stop at "accepted" (callexpired carries ended: true then). All of this presupposes attachMediaClient(): only the attached client's mediaconnected can disarm the watchdog. A call this device accepted or placed with no media client attached is not ended (1.9.2): at 70 s the SDK emits callunattached with a console warning, reports media_unattached to the platform, and leaves the call to the application. On 1.4.0 to 1.9.1 that call was released at 70 s (1.9.1: and ended on the server) — in every case, including calls whose media was fine. An invitation that merely rang here is released locally only (ended: false): ending it would hang up somebody else's call. An accepted that reaches this device because another device of the same user answered (the platform names it in answeredDeviceId) never arms the watchdog at all: the client compares it with its own device id (from the client token, or the platform's ready frame) and handles the update as answered_elsewhere — the ring stops, the invitation is released, and callupdated carries status: "answered_elsewhere", reason: "answered_elsewhere", exactly as for a recipient who is not you. The media client's join (with elapsedMs and steps), media establishment and connect timeout are also reported to the platform through the existing bounded diagnostics channel (the same one that carries signaling outages; no SDP, media, tokens or chat).

Complete managed integration

const phone = createRtcIncomingClient({
  tokenProvider: async () => {
    const response = await fetch("/api/rtc/client-token", { credentials: "include" });
    if (!response.ok) throw new Error("Unable to refresh RTC identity");
    return response.json();
  },
  signalingUrl,
  mount: "#incoming-call",
  // Built-in incoming and outgoing tones are enabled by default.
  // These URLs are optional and replace the built-in tones.
  ringtoneUrl: "/audio/incoming.mp3",
  ringbackUrl: "/audio/ringback.mp3",
  terminalStateDurationMs: 1600
});

// Call this synchronously from the first click/tap in your UI so later
// signaling events can play audio under browser autoplay policies.
await unlockRtcCallAudio();
const outgoing = await phone.startCall({ mode: "voice", target: { id: "user-42", name: "Alex" } });
const media = await createRtcClient({ mode: "voice", credential: outgoing.credential!, autoJoin: true });
phone.attachMediaClient(String(outgoing.call.id), media);

Set sounds: false to disable SDK call audio. If ringtoneUrl or ringbackUrl is omitted, the SDK synthesizes a default tone and does not require an audio asset. The built-in incoming-call UI keeps terminal states such as declined, busy, missed, failed, and ended visible for 1.6 seconds before closing; change this with terminalStateDurationMs.

Lifecycle

  1. Your backend holds the long-lived Server API key and exposes an authenticated application endpoint such as /api/rtc/client-token.
  2. tokenProvider calls that endpoint. The SDK refreshes the short-lived identity before expiry, before reconnect, and once after an HTTP 401.
  3. The client optionally opens /v1/rtc/signaling for presence and call events.
  4. The caller starts a call. Only the caller receives a media credential at this point.
  5. The callee accepts. The first accepting device wins atomically and receives its credential.
  6. Both clients initialize createRtcClient(), join media, and render tracks.
  7. Either side ends the call and destroys media/signaling clients during teardown.

The Server API key is long-lived until revoked and must remain on the application backend. Do not place it in JavaScript, an APK, an IPA, desktop binaries, or application configuration. The rtcc_... identity is deliberately short-lived and is not a deployment secret that staff should replace manually.

Bind the media client to every call you accept or place. This is required, not a convenience. Call phone.attachMediaClient(callId, media) as soon as accept() (or startCall()) resolves and before media.join(). The 70 s connect watchdog that accept() arms (and that the caller arms on accepted) is disarmed only by the attached client's mediaconnected; a call with no media client attached has no way to report that media came up. On 1.4.0 to 1.9.0 such a call was released at 70 s; on 1.9.1 the SDK also sent end, hanging up both sides of a call that was working. From 1.9.2 the SDK instead emits callunattached { id, expiresAt, message } once, logs a console warning, reports media_unattached to the platform (the call record then names the application that never attached), and leaves the call alone: nothing is ended or released, and you must end(callId) yourself. Attaching after that warning re-arms the watchdog for the attached client, so a call whose media never comes up is still freed from there. attachMediaClient() also stops statistics, leaves media, and releases camera/microphone tracks when either side receives a terminal call state. Integrations that do not use the incoming-call client must call await media.leave(); media.destroy() for ended, cancelled, rejected, busy, failed, and missed. Calling the same terminal action twice is safe.

Incoming-call client events on the watchdog:

| Event | Detail | | --- | --- | | callexpired | { id, expiresAt, ended } — the client released a call by itself: an unanswered ring or a call this device only heard accepted for (ended: false; nothing sent, the platform sweeps a missed ring), or a call this device accepted or placed whose attached media client reported no mediaconnected within the grace (ended: true; the SDK also sent end, so the other side hears ended) | | callunattached | { id, expiresAt, message } — a call this device accepted or placed reached the 70 s watchdog with no media client ever attached (1.9.2). Nothing is ended or released, because the SDK cannot tell this call from a working one. This is an integration error, not a failed call: attach the media client (attachMediaClient() before join()); until then end the call yourself | | callreleased | { id } after release(callId) |

join(), leave() and destroy()

join() on a client that is already connected or reconnecting is a no-op — no state change, no event. While the signaling socket is being rebuilt the client reports state reconnecting (as React Native does) and emits connected with resumed: true when it lands. A join that is refused (timeout, rejected token, socket error, or on the SFU transport a session or publish request that fails) rolls back like React Native: the tracks it acquired are stopped, the socket is closed, state is failed and error is emitted once (also with autoJoin: true). A localStream you passed in is left untouched by a refused join, so you can retry with it; leave() still stops it. Call join() again to retry; it acquires media again. Await leave() before calling join() again — client.leave(); client.join() without the await is a no-op join that ends disconnected.

leave() and destroy() may be called while join() is still waiting on the device prompt or the socket. The prompt's tracks are stopped as soon as it resolves, no socket is opened, the pending join() resolves without joining, and a destroyed client emits nothing further.

A first join whose socket closes before the roster arrives (the upgrade was refused, or the service closed it) emits disconnected and then error; state goes disconnectedfailed. Handle the refused join in the error handler (or the rejection of join()), as the example does.

Transports

transport: "mesh" (the default) opens one connection per pair, which suits direct calls and small rooms. transport: "sfu" sends media once to the platform SFU and receives each remote participant from it, which is what a large room needs — media no longer grows with the participant count. transport: "auto" picks the SFU for meeting mode and the mesh for voice and video calls. The credential, the events and the rest of the API are identical, so an application switches transports without changing its UI or call flow. SDP for the SFU travels over /v1/rtc/sfu/* authenticated by the same signaling token; no separate credential exists.

The SFU uplink publishes three simulcast layers (720p/360p/180p-class), so the SFU can hand every subscriber the layer their downlink carries. setQuality caps which layers are encoded; there is no per-connection tier on a single uplink, so peerquality is not emitted on this transport. To deliberately receive a lower layer for one participant — a thumbnail tile has no use for 720p — call setRemoteQuality(participantId, "low" | "medium" | "high") on the room client (getNativeClient()); the publisher keeps sending every layer. The React Native adapter has the same transport option and the same setRemoteQuality on the client.

simulcast: false publishes a single video encoding instead of the three-layer ladder — the right call on devices whose CPU or uplink cannot carry three encoders, such as phones on cellular networks. The announcement marks the track non-simulcast so subscribers never request a layer, and the quality ladder (manual setQuality or quality: "auto") shapes that one encoding's bitrate, framerate and scale instead of gating layers. The trade-off: subscribers lose per-viewer layer selection for this publisher. Mesh transport ignores the option. Defaults to true.

SFU transport resilience. A join that fails after signaling came up (SFU session or publish request rejected, or an SFU request that takes longer than 10 seconds) is rolled back — socket, uplink and session are closed, and the tracks the client acquired itself are stopped — and join() again starts a real new connect phase. Turning the camera on with setCameraEnabled(true), starting a screen share, or switching to a microphone after an audio-only join publishes the new track to the SFU and announces it, so the other participants subscribe to it; a screen share is announced and republished like a camera. If the uplink's connection state reaches failed, the client emits peerreconnecting with participantId: null (there is no single peer on this transport; reason is "failed", attempt counts rebuilds since the uplink was last connected), builds a new SFU session, republishes and resubscribes to every participant — and, if that rebuild itself fails, retries it with backoff (each retry is another peerreconnecting); the others see the new session id in the announcement and resubscribe on their side. The uplink reacts to failed only; the mesh's 5 s disconnected grace does not apply to it. Every SFU request is bounded to 10 seconds and never blocks signaling: heartbeats, the roster and chat keep flowing while a subscribe is in flight. After a signaling reconnect, participants missing from the roster are dropped with participantleft, and a participant who announces the same tracks from a new session is resubscribed. A reconnect attempt whose socket closes before the roster settles at once (it used to hang): each failed attempt is one informational signalingerror (RTC signaling connection closed) alongside reconnecting, and the client's state does not change on it. destroy() or leave() while the device prompt is open stops the acquired tracks and opens no socket; a join that times out closes its socket rather than entering the room later.

Virtual backgrounds and effects

createVideoEffectsPipeline(cameraTrack) draws the camera through a canvas and hands back a processed track for setVideoTrack, so both transports publish it unchanged. Backgrounds: blur, animated procedural scenes (aurora, cybergrid, bokeh, sunset), or your own image. Overlays: snow, confetti, vignette. Person cut-out uses any Segmenter you plug in (for example a self-hosted MediaPipe selfie model); without one the pipeline degrades honestly — blur blurs the whole frame, scenes show the camera as a floating card.

const effects = createVideoEffectsPipeline(cameraTrack, { segmenter });
effects.setBackground("aurora");
effects.setOverlay("confetti");
await media.setVideoTrack(effects.track);

Virtual avatar

createAvatarPipeline(cameraTrack, { tracker }) analyzes the person on-device and publishes an animated avatar instead of the camera — blink, gaze, brows, jaw, mouth and head pose all mirror the real face. The output is an ordinary canvas track for setVideoTrack, so both transports carry it unchanged and the camera never leaves the machine.

Face perception is pluggable: supply any FaceTracker (for example a self-hosted MediaPipe Face Landmarker emitting the 52 ARKit-style blendshape coefficients). Rendering is pluggable the same way — the built-in stylized renderer needs no assets, and a custom AvatarRenderer can draw a VRM character or anything else from the same AvatarRig.

Two modes: replace (avatar on a backdrop) and overlay (avatar anchored to the tracked head over the live camera). Client environments differ, so the perception constants are an open tuning surface, adjustable live:

const avatar = createAvatarPipeline(cameraTrack, {
  tracker,                 // your FaceTracker
  mode: "replace",
  tuning: { mouthGain: 1.7, eyeSync: 1, smoothing: 0.4 }
});
await media.setVideoTrack(avatar.track);
avatar.setTuning({ mouthGain: 2 });   // cameras differ; correct per deployment
avatar.setMode("overlay");

AvatarTuning covers mouthGain, blinkGain, eyeSync (evens the lids in profile, where per-eye data drifts), smoothing, avatarScale, avatarLift and lostHoldMs (losing the face holds the pose and eases the expression to rest instead of snapping to neutral).

Call recording

createCallRecorder(media.getNativeClient(), { credential }) composites every participant's video onto a canvas, mixes all audio, records with MediaRecorder, and on stop() uploads the file with the room signaling token. The platform stores it, bills the storage, lists it under GET /v1/rtc/recordings (Server API), and serves it from GET /v1/rtc/recordings/{id}/download. Recording happens on this client — what is recorded is what this participant saw and heard.

const recorder = createCallRecorder(media.getNativeClient(), { credential });
await recorder.start();
// ... later
const { recordingId, durationSeconds } = await recorder.stop();

Active speaker

The client samples WebRTC audio levels once a second on both transports and emits activespeaker ({ participantId }, or null when the room is silent). The choice has hysteresis — the highlight only moves when somebody is clearly louder than the current speaker — so two people at similar volume do not flap the indicator. Use it to light up the speaking tile:

media.addEventListener("activespeaker", ({ detail }) => {
  highlightTile(detail.detail.participantId);
});

Picture-in-picture

createPictureInPictureController(media.getNativeClient()) floats a participant's video in the browser's always-on-top window while the user works elsewhere. enter(participantId?) must run inside a click handler — browsers require a user gesture — and picks the first remote participant by default, falling back to the local preview. onLeave fires when the user closes the floating window; supported is false where the browser has no PiP.

import { createPictureInPictureController } from "@streaming-cdn/rtc-web";

const pip = createPictureInPictureController(media.getNativeClient(), {
  onLeave: () => showInlineVideoAgain()
});
pipButton.onclick = () => { void pip.enter(); };

Android and iOS system PiP require native application support and are on the SDK roadmap.

Picture enhancement

Low-bitrate video is blurry; enhancing it on the viewer's GPU turns a 540p stream into a sharp 1080p picture without costing the sender a byte. The engine ships as its own bundle (upscale.es.js) next to the SDK files, and loadUpscaler() fetches it on demand, so pages that never turn it on never download it and the core bundle does not grow.

import { loadUpscaler } from "@streaming-cdn/rtc-web";

const { createUpscaler, cssFilterFor, adjustNeedsCanvas } = await loadUpscaler();
// A canvas the page places over the remote <video> (same box, object-fit to match).
const enhancer = createUpscaler(remoteVideo, overlayCanvas, {
  mode: "ai",              // "off" | "basic" (denoise + edge-directed sharpen) | "ai"
  model: "auto",           // "auto" | "lite" | "standard" | "max"
  adjust: { contrast: 1.1, sharpness: 0.2, denoise: 0.4 }
});
enhancer.addEventListener("upscalechange", ({ detail }) => {
  showBadge(detail.effective.mode !== "off");   // tell the viewer the picture is enhanced
  console.log(detail.effective, detail.reason, detail.msPerFrame);
});
enhancer.start();
// later: enhancer.setOptions({ mode: "basic" }); enhancer.setAdjust({ gamma: 1.2 }); enhancer.destroy();

basic runs wherever WebGL2 does, at every display size (1x and downscale included): an optional mosquito-noise reduction (denoise > 0) that smooths only the flat pixels next to strong edges — the grain a compressed source leaves around text and line edges — and steadies still regions against the previous frame without ghosting on motion; then an edge-directed upscale when the display is larger than the source (an anti-ringing resample when it is not); then contrast-adaptive sharpening (a base amount plus sharpness), which lifts soft detail without amplifying edges that are already hard; then the colour chain. ai needs WebGPU and steps down to basic on its own when the device has no WebGPU, no model is available, or a tier does not fit the frame budget (auto measures the device first and keeps watching); it applies the same kind of sharpening after its model (a smaller base amount, measured so the model loses nothing), and there denoise steadies still regions the same way while the model handles the compression grain. When the received picture already matches the display, ai draws nothing (reason not-smaller) unless gamma, sharpness or denoise is set, in which case the basic pass applies them. getState() and every upscalechange carry the effective level, the reason for it, the measured ms per frame, and the GPU tier. adjust covers brightness, contrast, saturation, gamma, hue, sharpness and denoise (the last two 0..1, off at 0); with mode: "off", cssFilterFor(adjust) gives an equivalent CSS filter for the four the browser can express, and adjustNeedsCanvas(adjust) says when gamma, sharpness or denoise make the shader necessary (the engine then reports reason adjust).

Enhance received video only — never a local preview — and show the viewer that the picture is enhanced. Pass loadUpscaler(baseUrl) when a bundler relocates the SDK files.

Control ranges

sharpness and denoise are both 0..1, off at 0, and both are split at 0.5: the lower half is the range measured for fidelity on the reference clips (0.5 is the measured optimum), the upper half is the visibly stronger range a page chooses knowingly. The console's enhancement panel marks the split on the slider.

| Control | 0 – 0.5 | 0.5 – 1 | | --- | --- | --- | | sharpness | Contrast-adaptive sharpening on top of the level's base amount, clamped to the local 3×3 range: no halos, no ringing. | Adds a bounded detail boost on top: visibly stronger, with halos that are allowed but bounded and grow with the value. | | denoise | Mosquito-noise reduction at the measured radius and range sigma, strength scaling with the value; thin strokes and real texture are kept. | The smoothing widens (larger edge band, larger sigma, looser flat gate) and softens fine texture, and still regions are blended over time — motion-gated on the raw input, so moving content does not ghost. On ai, the input pre-pass runs whenever denoise > 0 and scales the same way. |

Leave both at or below 0.5 when fidelity matters (broadcast monitoring, text that must stay exact); go above when the viewer should see the difference.

In numbers (the constants live in basic-tuning.ts): from 0.5 to 1 the detail boost's gain grows 0 → 1.5 and its halo allowance 0 → 12 levels of 255 outside the local range; the denoiser's range sigma grows from about 6 to 15 levels, its edge search from 4 to 6 px, its strength from 0.75 to 1, and the temporal blend from 0.3 to 0.5. The reference report (scripts/upscale/basic_reference.py controlrange) states what the upper half costs in edge-band PSNR on the reference clips — at sharpness 1 about 3.5 dB on natural footage at 2x and 7 dB at 1x (text about 0.1 / 4 dB), at denoise 1 about 0.5 dB on natural footage (1.1 dB at 2000 kbit; text unchanged). It is a look, not a correction.

Model files, modelBaseUrl and the host page's CSP

The ai tiers are trained weights (lite.json, standard.json, max.json) that the npm package and the zip do not ship. The platform serves them, CORS-enabled for every origin, at

https://streaming-cdn.ewin888.com/upscale/models/{lite,standard,max}.json

Where the engine looks for them when you pass nothing:

  1. /upscale/models/ on the origin that served upscale.es.js — the right place when the SDK files are loaded from the platform host (or a staging host of it);
  2. then the platform host above, so a page that self-hosts the SDK files (npm install + your own bundler, a copied zip) still runs mode: "ai" instead of quietly stepping down to basic with reason model-missing.

The lookup costs one request per tier per page; a base that answers 404 (or with something that is not a model manifest) is remembered and never asked for that tier again, while a fetch that failed for a transient reason — a CSP block, a network error — is tried again on the next apply(). To serve the models yourself (a mirror, an air-gapped deployment), copy the three files and name their directory — an explicit modelBaseUrl is the only place the engine looks:

createUpscaler(remoteVideo, overlayCanvas, {
  mode: "ai",
  modelBaseUrl: "https://static.example.com/enhance-models/"   // {lite,standard,max}.json live here
});

The models are fetched with fetch(), so a host page with a Content Security Policy must allow the model host in connect-src (and the SDK host in script-src when upscale.es.js is loaded from the platform rather than from your own files):

Content-Security-Policy: connect-src 'self' https://streaming-cdn.ewin888.com;
                         script-src  'self' https://streaming-cdn.ewin888.com

A blocked or missing model is reported, never thrown: getState().reason and the next upscalechange say model-missing while effective.mode is basic — check for that in development, and read console.warn lines prefixed [upscale].

Lifecycle on the page

  • The engine watches the video for loadedmetadata, resize and emptied. When the stream goes offline, reconnects or is switched cold, the player detaches srcObject; the engine then reports effective.mode: "off" with reason idle and source: null, and presents nothing more — hide the overlay canvas on that event so the viewer sees the blank video (or your poster) instead of the last enhanced frame. The next loadedmetadata re-applies the requested mode on its own.
  • Leaving aisetOptions({ mode: "off" }) or "basic", a budget step-down, an emptied video, stop() — frees the tier's frame-sized GPU memory at once (several hundred MB at 1080p); the prepared pipelines stay, so switching back is quick. destroy() frees everything.

Quality and codecs

Use setQuality("low" | "medium" | "high" | "audio" | "auto") at runtime. auto is the default for video and uses interval packet loss, RTT, and the available outgoing bitrate plus encoder CPU/bandwidth limits to step down quickly and recover conservatively. It starts at medium; severe latency or loss falls directly to low, while promotion requires six healthy samples.

The requested quality is a ceiling, not an instruction. Calls are a peer mesh, so every participant has its own connection with its own link: each connection settles at or below the ceiling on its own evidence, and one participant on a poor network no longer decides what everybody else receives. A new connection starts one step below the ceiling and proves itself upward. Listen for peerquality ({ participantId, quality }) when one connection's tier moves. Use setPreferredVideoCodec("vp8" | "h264" | "auto"); compatible fallback codecs remain in the offer. packetLossPct is the loss measured since the previous sample. packetLossCumulativePct is the value since the call began.

Use iceTransportPolicy: "relay" to compare a poor direct carrier route with a relay-only route. The default all policy allows both direct and relay candidates. Relay-only mode requires valid relay credentials and should be chosen from measured RTT/loss data rather than enabled blindly.

See docs/RTC_SDK_INTEGRATION.md in the source repository for sequence diagrams and server examples.

Managed signaling uses a 20-second heartbeat and reconnects with jittered exponential backoff. WebSocket close code 1006 is recoverable but not a normal healthy close. A still-ringing invitation created during an outage is replayed after reconnection until its 45-second expiry; terminal calls are not. Abnormal close, reconnect, and recovery duration are also sent as bounded operational diagnostics. Tokens, SDP, media, and chat content are never part of that report. Set reportSignalingDiagnostics: false only when replacing this with an application-owned diagnostics pipeline.

1.9.3 notes

JavaScript-only; nothing on the platform changed. Every item is in the picture-enhancement engine (upscale.es.js, also served to the player as its engine) and comes from the 2026-09-14 review of the engine.

  • A manual model tier that is not deployed renders the largest deployed tier below it, and reason says model-missing with that tier in effectivemodel: "max" on a deployment that ships lite and standard now renders standard instead of dropping to the basic sharpen. No tier at all is still model-missing with effective.mode: "basic".
  • A GPU timestamp that reads 0 ms is not a measurement. Drivers that gate GPU timestamps and Chrome's quantisation of short passes resolved to identical stamps; the ladder climbed to the max tier "at 0 ms" and never stepped down. Below 0.05 ms the wall clock around the frame is used.
  • One failed AI frame no longer drops the AI path. importExternalTexture on a video with no current frame (a muted tile), a validation error on one frame, a readback rejected mid-resize: the frame is skipped, and only eight failures in a row fall back to basic — with the AI path probed again 30 s later instead of waiting for a size or option change. Device loss still falls back at once.
  • Without requestVideoFrameCallback (Firefox) the engine no longer re-enhances the same frame at the display rate: a tick on which the video's time has not advanced is skipped, and the stream's frame interval is learnt from it, so the budget matches the stream and not the display.
  • A model download cut mid-body is retried. It used to be cached as "not JSON" for the life of the page, parking that tier for every player on it; only a file that arrived whole and is not JSON is sticky now.
  • A source too large for the device's binding limit leaks nothing: every activation buffer is sized before any is created.
  • The player that embeds this engine (1.16.1) reports a hosting, CSP or bundling failure of the engine import as reason: "engine-unavailable" with an error event (upscale_engine_unavailable, recoverable), tries the playback origin after the sibling URL, and carries bundler-ignore comments on the import.

1.9.2 notes

JavaScript-only; the platform accepts one new diagnostic (media_unattached) from the same deployment. The React Native adapter's 1.7.2 rule, on the same day and for the same reason (letter of 2026-09-17, where an integrator that had never called attachMediaClient() — every document read it as optional — had every working call hung up at 70 s):

  • A call with no media client attached is no longer ended by the watchdog. attachMediaClient() is required (it always was: only the attached client's mediaconnected can disarm the 70 s watchdog), but an application that never called it had every answered call released at 70 s since 1.4.0, and from 1.9.1 also ended on the server — working calls included. From 1.9.2 the watchdog, on a call this device accepted or placed that never had a media client attached, emits callunattached { id, expiresAt, message } once, logs a console warning, and does nothing else: no end, no release. Calls with an attached client keep the full 1.9.1 behaviour, and so does a placed call that nobody answers (its ring window still releases it) and a device that merely heard accepted for a call it is not in. Attaching a media client after callunattached re-arms the watchdog for that client: if its media never comes up the call gets callexpired and is released 70 s later as on 1.9.1, and if media is already running the catch-up on the client's connect phase disarms it at once. The type is exported as RtcCallUnattachedEvent.
  • The platform records it. The same firing reports media_unattached through the diagnostics channel that carries media_joined, media_connected and media_timeout — call id, elapsedMs and sinceAcceptSeconds (measured from the accept, not from the last time an accepted echo reset the timer), and the reporting build — so a tenant's call record shows which application never attached. It is the one report a never-attached client ever makes.
  • The honest cost: a call that was never attached and really did not connect is no longer cleaned up for you at 70 s. It holds this page's call slot (startCall() refuses) until you end() / release() it, the platform sends a terminal update, or — with nobody in the media room — the platform sweeps it to ended a few minutes later. The event and the warning exist so that this is seen.

1.9.1 notes

Enhancement controls: sharpness and denoise now have a visibly different upper half — 0.5 keeps the measured optimum, above it sharpening may add bounded halos and denoising smooths harder and blends still regions over time; see "Control ranges". The AI level runs the denoiser on its input whenever denoise > 0.

JavaScript-only; the platform reads two new fields (clientVersion on the signaling join and on telemetry; elapsedMs / steps on media_joined) and ignores them on an older service. From the 2026-09-14 review of the connect phase, where an integrator reading the source line by line found our own definitions wrong:

  • mediaconnected means media is flowing. It used to fire on the track event as well as on connectionState: connected; the track event is raised while the remote description is applied, with ICE still new, so a call could be marked connected — and the incoming-call watchdog disarmed — with no packet ever exchanged. Now the peer's transport must be up (connectionState connected, or ICE connected / completed) and, when the peer sends you anything, its inbound bytesReceived / packetsReceived must be above zero (a track's unmute counts too, but only together with the transport). A peer that sends nothing counts on the transport alone. While a transport is up and the data has not arrived, the connection's stats are polled every 500 ms; the poll stops as soon as it has. remotestream is unchanged and now documented as what it always was: the remote description carried this track.
  • remotetrackactive { participantId, kind }: the peer's data of that kind arrived for the first time. Once per peer and kind.
  • connecttimeout peers, and mediaconnected itself, carry transportConnected, inboundBytes, inboundPackets, so "transport up, nothing received" is a state the event can name. getPeerStates() on the transport clients carries them too.
  • connectTimeoutMs counts from the later of join() and the first remote participant's arrival. It used to count from join() only, so a callee who joined first and waited 25 s for the caller got connecttimeout five seconds after the caller arrived. The event gains stage (waiting_for_peer / negotiating), joined, waitedForPeerMs and sinceFirstPeerMs; participantCount is 0 before the room is joined. It fires at most once per stage: a waiting_for_peer timeout does not use up the negotiating one for a peer that arrives later.
  • peerWaitTimeoutMs (default 0 = off) and peerwaittimeout { waitedMs } report, once, that nobody else has joined the room.
  • getConnectionDiagnostics() — the connecttimeout payload on demand, plus the media-connected bookkeeping and steps; the same shape as the React Native client's.
  • The call watchdog ends the call on the platform. callexpired used to release the call locally only: the other side's media client saw participantleft, its call client never saw call.updated: ended, and the call record stayed "accepted". For a call this device accepted or placed, expiry now also sends the end action (callexpired carries ended: true); an invitation that only rang here is still released locally only (ended: false).
  • Answered on another of your own devices. The platform now sends the answerer's other devices call.updated: accepted with answeredDeviceId. When its actor is this user and the device is not this one, the client handles it as answered_elsewhere (callupdated with status and reason answered_elsewhere; ring stopped, invitation released, no watchdog) instead of arming a 70 s watchdog for a call this device was never in. The client learns who it is from the rtcc_ token's claims (readClientTokenIdentity() is exported) and from the platform's ready frame, which wins. With an opaque token and no ready frame yet, the update stays a plain accepted, as in 1.9.0.
  • connected carries elapsedMs (since join()), and the media_joined report to the platform carries elapsedMs and steps: { localStreamMs, audioRouteMs, connectedMs }; getConnectPhase() and getConnectionDiagnostics() expose steps. audioRouteMs is always null in a browser.
  • The SDK version reaches the platform: clientVersion (web/1.9.1, exported as clientVersion; version stays the bare semver) on the rtc.room.join signaling frame, on every telemetry upload and on every media-phase diagnostic. Until now no participant record could say which build a call ran on.

1.9.0 notes

JavaScript-only; nothing on the service changed for it. The picture enhancement's basic level was rebuilt for H.264 sources (2026-09-14):

  • basic now runs at every display size, 1x and downscale included. Before, it only did anything when the display was larger than the source (an unsharp mask on a bicubic upscale, measured within 0.03 dB of plain bicubic — invisible). onlyWhenSmaller and reason not-smaller now apply to ai only.
  • New adjust.denoise (0..1, default 0): mosquito-noise reduction for the ringing a compressed source leaves beside text and line edges — an edge-gated bilateral at source resolution that smooths only the flat pixels next to a strong edge, plus a temporal settle of still regions, clamped to the current frame and dropped wherever the picture moved, so it cannot ghost. On the reference clips it is the one operation that improves fidelity everywhere (edge-band PSNR +0.2 to +0.4 dB); 0.5-0.7 is the sensible setting for a 400-1200 kbit source. adjustNeedsCanvas is true for it; cssFilterFor ignores it (no CSS equivalent); it is written to slot 7 of the adjust uniform.
  • sharpness keeps its name and range but now drives contrast-adaptive sharpening instead of an unsharp mask, on both levels: the amount scales with each pixel's headroom to its neighbourhood and the result is clamped to that neighbourhood, so hard edges and the ringing beside them are not pushed further. basic always applies a base amount (larger when upscaling, small at 1x where sharpening re-contrasts noise); ai applies a smaller base after the model. Upscaling is edge-directed (12 taps, deringed) above 1x and an anti-ringing resample at 1x and below (a 2x-or-more downscale is area-averaged).
  • resetHistory() on both renderers; the engine drops temporal history on seek, source switch, resize and a denoise toggle.

1.8.0 notes

JavaScript-only; nothing on the service changed for it. From the second global scan (2026-09-14), the media client on both transports:

  • leave() / destroy() during a pending join() stop the tracks the permission dialog hands over, open no socket and settle the pending join() quietly; a destroyed client emits nothing further (web-3). Before, the join completed behind the leave with a hot camera and a socket nobody held.
  • A refused join rolls back (web-10): the tracks the client acquired are stopped, the socket closed, state failed, error once (also with autoJoin); a localStream you handed in is kept for the retry. join() on a connected or reconnecting client is a no-op, and state reads reconnecting while the socket is rebuilt (web-8). A join that never receives its roster closes its socket after 15 s and can no longer enter the room late (web-7).
  • Mesh: a failed peer connection is repaired, not dropped (web-6). New event peerreconnecting { participantId, reason, attempt }: the client restarts ICE (disconnected gets 5 s to heal first), the peer stays in the roster, and participantleft means the peer left. Both sides need this version for the repair; an unknown sender's ICE candidate no longer conjures a ghost peer. Restarts are not capped.
  • SFU: the uplink is rebuilt when it fails (web-6) — peerreconnecting with participantId: null, new session, republish, resubscribe, retried with backoff. A camera, microphone or screen share started after an audio-only join is published and announced (web-5); a refused publish rolls the join back so the retry is real (web-4); every SFU request is bounded to 10 s and never blocks the signaling queue (web-9); a reconnect roster drops the participants who left and resubscribes a participant who came back with a new session (web-11). See Transports.
  • TypeScript: RtcPeerReconnectingEvent.participantId is string | null (null on the SFU transport). Exported: RtcPeerReconnectingEvent.
  • Picture enhancement finds its models from any host. The engine's default model location is no longer the bare path /upscale/models/ on whatever origin the page lives on: it is that path on the origin that served upscale.es.js, and then the platform host, which serves the models CORS-enabled. A self-hosted SDK (npm + bundler) now runs mode: "ai" instead of stepping down to basic with model-missing. modelBaseUrl and the host page's connect-src are documented under Picture enhancement. Exported: DEFAULT_MODEL_BASE_URL, PLATFORM_MODEL_BASE_URL.
  • Enhancement follows the stream. The engine listens for emptied: when the player detaches the video's srcObject (offline, reconnect, cold source switch) the engine reports off/idle with source: null and presents nothing more, instead of leaving the last enhanced frame on the overlay. Leaving ai for off/basic, a budget step-down or an emptied video now frees the tier's frame-sized GPU memory immediately (it used to stay allocated until stop()); a probe interrupted by stop() renders no further frame.
  • The web example shows "Connected" only on mediaconnected and handles connecttimeout, peerreconnecting, reconnecting, disconnected and a refused join (ops-11).

1.7.0 notes

Ship it after the platform release that accepts it: a 1.7.0 client against an older service fails every SFU publish (rtc_sfu_tracks_required).

  • Presence deltas. createRtcIncomingClient() opens its signaling socket with ?presence=delta: the service sends the online list once and then one small presence.delta frame per arrival or departure, instead of the whole list to every device on every change. The SDK folds those into the list it keeps and still emits presence { users } on each change, so renderOnlineUsers(event.detail.users) keeps working unchanged; the change itself is available as presencedelta ({ change, scope, roomId?, user }). A meeting participant's socket now lists only that meeting's participants.
  • SFU wire vocabulary. The /v1/rtc/sfu/* request and reply bodies use the platform's own field names (publish, subscribe, layers, mids, description, layer: high | medium | low). Public API paths are unchanged.

1.6.1 notes

No API change. Runtime quality telemetry is now confined to the joined session.

  • Stats sampling starts after join() succeeds, not in initialize(). Nothing is posted between initialize() and join(), so a preflight or device-picker page no longer reports an initialized sample.
  • Sampling stops on every terminal state: a rejected join() (refused token, closed room, 4001 close), the disconnected event, leave() and destroy(). A page that keeps the client after a failed join used to post a failed sample every 30 s for as long as it stayed open. A later join() starts sampling again.
  • leave() sends its final sample only if the session had joined. The server also discards runtime samples from a participant it has already marked as left (HTTP 204, nothing stored) and closes any alert still open for that participant; the final sample of a call ended through the API a few minutes earlier is still kept.

1.6.0 notes

  • Picture enhancement. loadUpscaler() loads the viewer-side enhancement engine on demand (its own upscale.es.js bundle, shipped in this package): a basic sharpening upscale on WebGL2, or ai enhancement on WebGPU with tiered models (lite / standard / max, or auto, which measures the device and keeps watching the frame budget), plus brightness, contrast, saturation, gamma, hue and sharpness. See Picture enhancement above. The core bundle is unchanged in size; nothing is downloaded until it is used. JavaScript only.

1.5.0 notes

New events and one option on the media client; nothing existing changes shape.

  • loadUpscaler() loads the viewer-side picture-enhancement engine on demand (see Picture enhancement); the engine's types are exported from the package.

  • mediaconnected is the "call connected" signal. connected was documented as it, and it only ever meant the signaling room was joined. Two calls on 2026-09-11 were marked connected with no media; the docs were the defect.

  • negotiation, icestate narrate the connect phase step by step — message types and timing only, never SDP or candidate bodies.

  • connectTimeoutMs (default 30 000; 0 disables) and the connecttimeout event, which lists every peer's connection, ICE and signaling state plus the last negotiation phase. The client does not leave on its own.

  • participantjoined fires for peers already in the room, marked initial: true. The later arrival used to get no notice at all that the other side was there.

  • signalingerror { code: "send_dropped" } when a signaling frame is dropped because the socket is not open. Still no throw.

  • The final quality sample is taken from a snapshot made before any connection is closed, so it describes the call rather than the teardown; a final sample that lands on a running runtime sample now waits its turn instead of being skipped. Samples also carry statsTransport, statsConnectionCount, statsRowCount and statsRowTypes.

  • attachMediaClient() keeps the call watchdog armed until the media client reports mediaconnected. Attaching happened exactly when the unguarded media phase began, so attaching used to end the only watch. Media join, media establishment and connect timeout are reported to the platform through the existing diagnostics channel, with the call id.

  • Staged connect budget published above: join 20 s, wait for the other side at least 20 s without hanging up, negotiation 15 s, give up no earlier than 45 s — anchored at the moment the other side is in the room.

1.4.0 notes

  • Call-slot hardening ported from React Native. Every path that could leave a call id in the active set for the life of the page — hang-up failing on the server, an unanswered outgoing call, an accepted call whose media never came up, the accepted broadcast reaching a session holding no media, a startCall in flight across disconnect() — now releases the slot, so the next call always works.

1.3.28 notes

  • The idle pose measures which way is down. 1.3.27 lowered the arms out of T-pose with a guessed rotation sign; on real rigs the guess raised every arm skyward, and the crown measurement then framed the raised fingertips as the top of the head. Each arm now tries both signs and keeps the one that puts the wrist lower, so the pose is correct for any facing convention or rig variant.

1.3.27 notes

  • VRM busts no longer clip at the canvas sides. Characters ship in T/A-pose; the square stage cut the outstretched arms and shoulders off with hard vertical edges. The stage now lowers the arms into a natural idle before measuring, sizes its width to the character's own measured body (floor 4:3, cap 2:1 — frameVrmBust returns the aspect), and the composite dissolves the bottom edge instead of stopping at a hard line inside the tile. The stage aspect is the avatar sprite's capture field only: output portrait/landscape orientation is unchanged and keeps following the camera source.

1.3.26 notes

  • VRM avatars are framed from the model's own measurements. The bust camera in avatar-vrm was fixed at a 0.42 m window aimed at the head joint — which sits at the bottom of the skull — so characters with tall hair or accessories had their crowns cropped, and big-headed models filled the whole canvas and rendered far larger than the built-in avatar when overlaid. createVrmStage now measures each model (head joint from the humanoid, crown from the scene bounds, hair included) and frames the bust so the skull spans ~38% of the canvas with its centre at 42% from the top — the exact contract vrmAvatarRenderer's overlay math expects. Every character now appears the same size as the built-in stylized avatar, whatever its proportions. The framing rule is exported as frameVrmBust().

1.3.25 notes

  • B21 — per-peer quality no longer ratchets down on bandwidth-pinned links. The step-up gate required qualityLimitationReason to clear; on links that pin it at bandwidth, upgrades were mathematically impossible. Both directions now share bandwidthSustainRatio (default 0.7): down judges the current tier's target, up judges the next tier's, so promotions cannot flap. bandwidthHeadroomRatio is retired and ignored, and goodSamplesToStepUp is effective again in this state.

1.3.24 notes

  • callupdated now carries actorExternalId, matching the React Native SDK. For recipient_rejected and recipient_busy this names the recipient the update is about, so a caller ringing several people can tell who declined. It is null for updates that are about the call as a whole.

1.3.21 notes

  • Per-peer quality: a bandwidth limitation reason counts against a link only when the measured uplink does not comfortably clear the current tier (bandwidthHeadroomRatio, default 1.25) — encoder ramp-up during the first minute no longer forces a downgrade.
  • Quality samples carry direction-explicit resolutions: inboundFrameWidth/Height (received; equals the historical frameWidth/Height) and outboundFrameWidth/Height (what your encoder sends).