@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
- Your backend holds the long-lived Server API key and exposes an authenticated application endpoint such as
/api/rtc/client-token. tokenProvidercalls that endpoint. The SDK refreshes the short-lived identity before expiry, before reconnect, and once after an HTTP401.- The client optionally opens
/v1/rtc/signalingfor presence and call events. - The caller starts a call. Only the caller receives a media credential at this point.
- The callee accepts. The first accepting device wins atomically and receives its credential.
- Both clients initialize
createRtcClient(), join media, and render tracks. - 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 disconnected → failed. 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}.jsonWhere the engine looks for them when you pass nothing:
/upscale/models/on the origin that servedupscale.es.js— the right place when the SDK files are loaded from the platform host (or a staging host of it);- then the platform host above, so a page that self-hosts the SDK files
(
npm install+ your own bundler, a copied zip) still runsmode: "ai"instead of quietly stepping down tobasicwith reasonmodel-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.comA 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,resizeandemptied. When the stream goes offline, reconnects or is switched cold, the player detachessrcObject; the engine then reportseffective.mode: "off"with reasonidleandsource: 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 nextloadedmetadatare-applies the requested mode on its own. - Leaving
ai—setOptions({ 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
reasonsaysmodel-missingwith that tier ineffective—model: "max"on a deployment that ships lite and standard now renders standard instead of dropping to the basic sharpen. No tier at all is stillmodel-missingwitheffective.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.
importExternalTextureon 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 anerrorevent (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'smediaconnectedcan 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, emitscallunattached { id, expiresAt, message }once, logs a console warning, and does nothing else: noend, 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 heardacceptedfor a call it is not in. Attaching a media client aftercallunattachedre-arms the watchdog for that client: if its media never comes up the call getscallexpiredand 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 asRtcCallUnattachedEvent. - The platform records it. The same firing reports
media_unattachedthrough the diagnostics channel that carriesmedia_joined,media_connectedandmedia_timeout— call id,elapsedMsandsinceAcceptSeconds(measured from the accept, not from the last time anacceptedecho 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 youend()/release()it, the platform sends a terminal update, or — with nobody in the media room — the platform sweeps it toendeda 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:
mediaconnectedmeans media is flowing. It used to fire on thetrackevent as well as onconnectionState: connected; thetrackevent is raised while the remote description is applied, with ICE stillnew, 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 (connectionStateconnected, or ICEconnected/completed) and, when the peer sends you anything, its inboundbytesReceived/packetsReceivedmust be above zero (a track'sunmutecounts 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.remotestreamis 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.connecttimeoutpeers, andmediaconnecteditself, carrytransportConnected,inboundBytes,inboundPackets, so "transport up, nothing received" is a state the event can name.getPeerStates()on the transport clients carries them too.connectTimeoutMscounts from the later ofjoin()and the first remote participant's arrival. It used to count fromjoin()only, so a callee who joined first and waited 25 s for the caller gotconnecttimeoutfive seconds after the caller arrived. The event gainsstage(waiting_for_peer/negotiating),joined,waitedForPeerMsandsinceFirstPeerMs;participantCountis0before the room is joined. It fires at most once per stage: awaiting_for_peertimeout does not use up thenegotiatingone for a peer that arrives later.peerWaitTimeoutMs(default0= off) andpeerwaittimeout { waitedMs }report, once, that nobody else has joined the room.getConnectionDiagnostics()— theconnecttimeoutpayload on demand, plus the media-connected bookkeeping andsteps; the same shape as the React Native client's.- The call watchdog ends the call on the platform.
callexpiredused to release the call locally only: the other side's media client sawparticipantleft, its call client never sawcall.updated: ended, and the call record stayed "accepted". For a call this device accepted or placed, expiry now also sends theendaction (callexpiredcarriesended: 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: acceptedwithansweredDeviceId. When its actor is this user and the device is not this one, the client handles it asanswered_elsewhere(callupdatedwithstatusandreasonanswered_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 thertcc_token's claims (readClientTokenIdentity()is exported) and from the platform'sreadyframe, which wins. With an opaque token and noreadyframe yet, the update stays a plainaccepted, as in 1.9.0. connectedcarrieselapsedMs(sincejoin()), and themedia_joinedreport to the platform carrieselapsedMsandsteps: { localStreamMs, audioRouteMs, connectedMs };getConnectPhase()andgetConnectionDiagnostics()exposesteps.audioRouteMsis always null in a browser.- The SDK version reaches the platform:
clientVersion(web/1.9.1, exported asclientVersion;versionstays the bare semver) on thertc.room.joinsignaling 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):
basicnow 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).onlyWhenSmallerand reasonnot-smallernow apply toaionly.- 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.adjustNeedsCanvasis true for it;cssFilterForignores it (no CSS equivalent); it is written to slot 7 of the adjust uniform. sharpnesskeeps 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.basicalways applies a base amount (larger when upscaling, small at 1x where sharpening re-contrasts noise);aiapplies 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 pendingjoin()stop the tracks the permission dialog hands over, open no socket and settle the pendingjoin()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,erroronce (also withautoJoin); alocalStreamyou handed in is kept for the retry.join()on aconnectedorreconnectingclient is a no-op, and state readsreconnectingwhile 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 (disconnectedgets 5 s to heal first), the peer stays in the roster, andparticipantleftmeans 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) —
peerreconnectingwithparticipantId: 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.participantIdisstring | 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 servedupscale.es.js, and then the platform host, which serves the models CORS-enabled. A self-hosted SDK (npm + bundler) now runsmode: "ai"instead of stepping down tobasicwithmodel-missing.modelBaseUrland the host page'sconnect-srcare 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'ssrcObject(offline, reconnect, cold source switch) the engine reportsoff/idlewithsource: nulland presents nothing more, instead of leaving the last enhanced frame on the overlay. Leavingaiforoff/basic, a budget step-down or an emptied video now frees the tier's frame-sized GPU memory immediately (it used to stay allocated untilstop()); a probe interrupted bystop()renders no further frame. - The web example shows "Connected" only on
mediaconnectedand handlesconnecttimeout,peerreconnecting,reconnecting,disconnectedand 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 smallpresence.deltaframe 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 emitspresence { users }on each change, sorenderOnlineUsers(event.detail.users)keeps working unchanged; the change itself is available aspresencedelta({ 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 ininitialize(). Nothing is posted betweeninitialize()andjoin(), so a preflight or device-picker page no longer reports aninitializedsample. - Sampling stops on every terminal state: a rejected
join()(refused token, closed room,4001close), thedisconnectedevent,leave()anddestroy(). A page that keeps the client after a failed join used to post afailedsample every 30 s for as long as it stayed open. A laterjoin()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 (HTTP204, 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 ownupscale.es.jsbundle, shipped in this package): abasicsharpening upscale on WebGL2, oraienhancement on WebGPU with tiered models (lite/standard/max, orauto, 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.mediaconnectedis the "call connected" signal.connectedwas 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,icestatenarrate the connect phase step by step — message types and timing only, never SDP or candidate bodies.connectTimeoutMs(default 30 000;0disables) and theconnecttimeoutevent, which lists every peer's connection, ICE and signaling state plus the last negotiation phase. The client does not leave on its own.participantjoinedfires for peers already in the room, markedinitial: 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,statsRowCountandstatsRowTypes.attachMediaClient()keeps the call watchdog armed until the media client reportsmediaconnected. 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
acceptedbroadcast reaching a session holding no media, astartCallin flight acrossdisconnect()— 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 —
frameVrmBustreturns 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-vrmwas 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.createVrmStagenow 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 contractvrmAvatarRenderer'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 asframeVrmBust().
1.3.25 notes
- B21 — per-peer quality no longer ratchets down on bandwidth-pinned
links. The step-up gate required
qualityLimitationReasonto clear; on links that pin it atbandwidth, upgrades were mathematically impossible. Both directions now sharebandwidthSustainRatio(default 0.7): down judges the current tier's target, up judges the next tier's, so promotions cannot flap.bandwidthHeadroomRatiois retired and ignored, andgoodSamplesToStepUpis effective again in this state.
1.3.24 notes
callupdatednow carriesactorExternalId, matching the React Native SDK. Forrecipient_rejectedandrecipient_busythis names the recipient the update is about, so a caller ringing several people can tell who declined. It isnullfor updates that are about the call as a whole.
1.3.21 notes
- Per-peer quality: a
bandwidthlimitation 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 historicalframeWidth/Height) andoutboundFrameWidth/Height(what your encoder sends).
