ILORB Signal developers
Real-time voice rooms for your app: a stage with many microphones and an audience listening, or a voice call where everyone talks. ILORB Signal runs on the Cloudflare global network. You keep your users, your rooms and your rules; we carry the audio.
https://api-signal.ilorb.app. Voice rooms, voice calls, video (one camera per person, up to 9 on screen) and linked rooms (PK) are available today; screen sharing is in beta. Items marked BETA are tested in the lab and on emulators but not yet used in production; PREVIEW means it is written and tested on our development server but not live on api-signal.ilorb.app yet — it arrives with the next server release (ask us for early access). Platform tags (BROWSER, ANDROID) say where a feature works today. Cloud recording is in beta; large live audiences are on the roadmap.Core concepts
| Concept | What it is |
|---|---|
| Account | Your company in ILORB Signal. It owns the prepaid credit, the projects and the console logins. |
| Project | One app. Each project has a public App ID (goes in the app) and a private App Secret (only on your server). If you have several apps, create one project per app to keep usage and keys apart. |
| User token | A short-lived signed token your server creates for one user. The app gives it to the SDK to join a room. The App Secret never reaches the phone. |
| Room | Identified by any roomId you choose ([0-9A-Za-z_-], up to 80 chars). It opens when the first person joins and closes when the last one leaves; there is nothing to create beforehand. |
| Publishing | Being on stage: your microphone is sent to everyone in the room. Listeners don't publish and cost nothing to send. |
| Participant-minute | The billing unit: one person connected to a room for one minute, speaking or not. |
Quickstart
1. Create a project
In the console, open Projects → Create project, pick Voice room and copy the App ID and App Secret. The secret is shown only once; you can rotate it later.
2. Issue a token on your server
import { createUserToken } from '@ilorb/signal-server'
// e.g. inside your authenticated "join room" endpoint
const token = await createUserToken({
appId: process.env.ILORB_APP_ID,
appSecret: process.env.ILORB_APP_SECRET,
userId: user.id, // your user id, [0-9A-Za-z_-] up to 40 chars
roomId: 'room-42', // optional: lock the token to one room
expiresInSeconds: 3600, // optional: default 24 h, max 7 days
})
3. Join from the app
import { VoiceClient } from '@ilorb/signal'
const voice = new VoiceClient({ serverUrl: 'https://api-signal.ilorb.app' })
voice.on('userJoined', (u) => console.log('joined', u.userId))
voice.on('soundLevels', (levels) => drawWaves(levels)) // { userId: 0–100 }
voice.on('error', (e) => console.warn(e.code, e.message))
await voice.joinRoom('room-42', tokenFromYourServer, { name: 'Ana' })
await voice.startPublishing() // go on stage (asks for the microphone)
voice.muteMicrophone(true) // stays on stage, sends nothing
Open the same room in a second tab or phone and the two of you hear each other. The console's Test room does exactly this without writing any code.
User tokens
A user token is a JWT signed with HS256 using your App Secret. Your server creates it; the SDK sends it when connecting; ILORB Signal checks the signature, the expiry and the room.
| Claim | Type | Meaning |
|---|---|---|
app | string | Your App ID. |
sub | string | The user id in your system ([0-9A-Za-z_-], 1–40 chars). It is the userId everyone else sees. |
room | string? | If present, the token only opens this room. Without it, any room of the project. |
iat / exp | number | Issued at / expires at (Unix seconds). Lifetime between 60 s and 7 days; default 24 h. |
admin | boolean? | Can change locked room attributes. |
role | "viewer"? | Audience only: watches, never publishes, not listed to anyone — see viewers. Any other value is a normal person. |
createUserToken(options)
| Option | Required | Description |
|---|---|---|
appId | yes | Project App ID. |
appSecret | yes | Project App Secret. Keep it on the server. |
userId | yes | Your user id. |
roomId | no | Lock the token to one room (recommended). |
expiresInSeconds | no | Default 86 400, clamped to 60 … 604 800. |
admin | no | true adds the claim admin: this user can change the room attributes even when your server locked them to admins (room owner, moderators). Tokens without it are unchanged. |
role | no | 'viewer' adds the claim role: a viewer (audience). Any other value throws. Server SDKs: role="viewer" (Python), $role: 'viewer' (PHP), Role: "viewer" (Go), the last argument of createUserToken (Java, .NET). Tokens without it are unchanged. |
@ilorb/signal-server uses only WebCrypto, so it runs on Node 18+, Cloudflare Workers, Deno, Bun and the browser. It also exports verifyUserToken(token, appSecret) and peekAppId(token). In any other language, sign the claims above as a standard HS256 JWT.
To debug, the console has Tools → Generate test token (valid 24 h) and Verify token, which tells you whether a token is valid, which project it belongs to and when it expires.
Server credentials
The REST API is for your server only. Authenticate every call with HTTP Basic using the project credentials:
Authorization: Basic base64("<App ID>:<App Secret>")
curl -u "$ILORB_APP_ID:$ILORB_APP_SECRET" https://api-signal.ilorb.app/v1/rooms
Rotating the App Secret in the console invalidates the old one immediately for new tokens and REST calls. People already in a room stay connected.
Server SDKs BETA
Libraries for your backend (they hold the App Secret, so never ship them in the app). Each one does the same four things, with the language's standard library only:
- User token —
createUserToken, byte-for-byte identical to@ilorb/signal-server(same claims, same order), plusverifyUserTokenandpeekAppId. - Signed callbacks —
verifyCallbackfor room and chat callbacks:x-ilorb-signature = sha256=hex(HMAC-SHA256(callback_secret, "<x-ilorb-timestamp>.<raw body>")), 300 s tolerance. Pass the raw body as received, not re-serialized JSON. - Rooms (
api-signal.ilorb.app):balance,listRooms,roomState,kick,close,link,unlink,broadcast,setAttributes(room & member attributes). The token also takesadmin. - Chat (
chat-signal.ilorb.app): moderation, ban / unban, reports, send a message (alsosystem), revoke, set a profile, push test and callback log.
Version 0.2.0 (2026-11-02) adds, in all four languages: the callback eventId receiver (handle each event once), call push to FCM / APNs VoIP, cloud mix, recording (whole room and per person), RTMP output and the cloud player, content moderation (settings, flags, verdicts, bans) and Live Shop (store, catalogue, pins).
| Language | Package | Dependencies | Status |
|---|---|---|---|
| TypeScript / JS | @ilorb/signal-server | none (WebCrypto) | Reference |
| Python 3.9+ | ilorb-signal-server (import ilorb_signal) | standard library (push extra: cryptography, httpx) | Tested 0.2.0 |
| Go 1.21+ | signal.ilorb.com/server | standard library | Tested 0.2.0 |
| Java 17+ (and Kotlin) | com.ilorb.signal:ilorb-signal-server | java.net.http, javax.crypto | Tested 0.2.0 |
| PHP 8.1+ | ilorbsignal/server (namespace IlorbSignal\) | ext-json (curl, openssl suggested) | Tested 0.2.0 — first run 2026-11-02 |
"Tested" means: tokens identical to the TypeScript ones (fixed test vectors), tampered or expired tokens and callbacks refused, unit tests, the local API, and a real join and leave against our development server. Available on request: the packages are not on PyPI, the Go proxy, Maven Central or Packagist yet, so pip install / go get / Maven / composer require do not work today — ask us and we send the package (the names above are the ones they will be published under).
# Python
from ilorb_signal import create_user_token, verify_callback, RoomsClient, ChatServerClient
token = create_user_token(APP_ID, APP_SECRET, "ana", room_id="room-1", expires_in_seconds=3600)
ok = verify_callback(CB_SECRET, request.headers["x-ilorb-timestamp"], request.get_data(), request.headers["x-ilorb-signature"])
RoomsClient(APP_ID, APP_SECRET).kick("room-1", "bruno")
ChatServerClient(APP_ID, APP_SECRET).send_message("shop", "peer", "ana", {"type": "system", "text": "Order paid"})
// Go
import ilorbsignal "signal.ilorb.com/server"
tok, err := ilorbsignal.CreateUserToken(appID, appSecret, "ana", ilorbsignal.TokenOptions{RoomID: "room-1", ExpiresInSeconds: 3600})
body, _ := io.ReadAll(r.Body) // the RAW body
ok := ilorbsignal.VerifyCallback(cbSecret, r.Header.Get("x-ilorb-timestamp"), body, r.Header.Get("x-ilorb-signature"), 0) // 0 = 300 s
ilorbsignal.NewRoomsClient(appID, appSecret).Kick(ctx, "room-1", "bruno")
ilorbsignal.NewChatServerClient(appID, appSecret).BanUser(ctx, "bruno", 3600, "spam")
// Java (also Kotlin)
String tok = IlorbSignal.createUserToken(APP_ID, APP_SECRET, "ana", "room-1", 3600);
boolean ok = IlorbSignal.verifyCallback(CB_SECRET, req.getHeader("x-ilorb-timestamp"), rawBody, req.getHeader("x-ilorb-signature"));
new RoomsClient(APP_ID, APP_SECRET).broadcast("room-1", "score", Map.of("a", 3));
new ChatServerClient(APP_ID, APP_SECRET).banUser("bruno", 3600, "spam");
// PHP
use IlorbSignal\{Auth, RoomsClient, ChatServerClient};
$tok = Auth::createUserToken($appId, $appSecret, 'ana', roomId: 'room-1', expiresInSeconds: 3600);
$ok = Auth::verifyCallback($cbSecret, $_SERVER['HTTP_X_ILORB_TIMESTAMP'], file_get_contents('php://input'), $_SERVER['HTTP_X_ILORB_SIGNATURE']);
(new RoomsClient($appId, $appSecret))->kick('room-1', 'bruno');
API errors become an exception (or Go *ilorbsignal.Error) with the HTTP status and a code: Python IlorbSignalError, Java and PHP IlorbSignalException.
Install the client SDK
npm install @ilorb/signal
npm install @ilorb/signal-server # on your server
Without a bundler, load the browser build. It exposes the global IlorbSignal:
<script src="https://api-signal.ilorb.app/sdk/ilorb-signal.js"></script>
<script>
const voice = new IlorbSignal.VoiceClient({ serverUrl: 'https://api-signal.ilorb.app' })
</script>
VoiceClient
new VoiceClient(options: VoiceClientOptions)
| Option | Type | Description |
|---|---|---|
serverUrl | string | https://api-signal.ilorb.app |
webrtc | WebRTCImpl? | WebRTC implementation. Browser: the global one (default). React Native: @livekit/react-native-webrtc. |
audio | Partial<AudioOptions>? | Initial audio options. |
hooks | AudioHooks? | Lets your app prepare the system audio session and plug native processing. See types. |
soundLevelIntervalMs | number? | How often soundLevels fires. Default 400. |
autoReconnect | boolean? | Reconnect by itself when the network drops. Default true. |
video | Partial<VideoOptions>? | Camera options (video), including simulcast. |
autoSubscribeVideo | boolean? | Receive every camera automatically. Default true. |
lowNetworkVideo | 'keep' | 'audio-only' | What to do with a camera that freezes on a bad network. Default 'keep'. See poor network. |
device | { brand?, model?, os?, appVersion? } | The device, for diagnostics (React Native can't detect it by itself). |
sendStats | boolean? | Send connection stats to the console diagnostics. Default true. |
statsIntervalMs | number? | How often stats are sent. Default 30 000. |
screenCapture | () => Promise<stream | { stream, track }> | Default screen capture for startScreenShare. React Native: () => IlorbScreen.start({ pickerRef }). Browser: not needed (uses getDisplayMedia). |
autoSubscribeScreen | boolean? | Receive every shared screen automatically. Default true. |
Methods
| Method | Description |
|---|---|
joinRoom(roomId, token, { name? }) | Joins the room with a token from your server. Resolves when connected. Leaves the current room first if there is one. |
leaveRoom() | Leaves the room and releases the microphone. |
startPublishing() | Goes on stage: opens the microphone and sends it to the room. |
stopPublishing() | Leaves the stage. |
muteMicrophone(muted) | Mutes my microphone. I stay on stage, so unmuting is instant, but while muted zero packets are sent. |
muteRoom(muted) | Stops hearing the whole room (volume only), or hears it again. |
muteUser(userId, muted) | Stops hearing one person, only for me. Cancels that subscription, so nothing is received or billed; unmuting resubscribes in about 0.5 s. |
setReceiving(enabled) | Turns receiving the whole room on/off. Off: nothing arrives and nothing is billed for egress. Use it when the app has been in the background for a while. |
isConnectionAlive() | Resolves true if the socket answers a ping within 1.5 s and no audio connection has failed. |
resume() | Call when the app returns to the foreground. If the connection is alive it does nothing (audio is back immediately); if not, it reconnects right away. |
reconnectNow() | The internet is back: if the SDK is reconnecting, try now instead of waiting for the next attempt. In the browser the SDK listens to online by itself; in React Native call it from NetInfo. |
getConnectionInfo() | State of each connection right now (socket, receive, mic, camera, last error). See diagnostics. |
setUserVolume(userId, volume) | Volume of one person, only for me (1 = normal). Volume only: to save traffic use muteUser. |
setAudioOptions(partial) | Changes audio options. Publishing options (bitrate, stereo, DTX, AEC/NS/AGC) republish the microphone. |
sendRoomMessage(message, topic?) | Sends a text to everyone else in the room (fires roomMessage on their side). |
renewToken(token) | Replaces the token used when the SDK reconnects (the server checks the token only when a connection opens). Call it with a new token for the same user before the old one expires, for sessions longer than the token. |
setPosition, setAudioRange, setTeam, setRangeMode, getRangeInfo | Range voice & teams: volume and receiving by distance and team. |
setRoomAttributes, deleteRoomAttributes, getRoomAttributes, setMyAttributes | Room and member attributes kept by the room. |
getNetworkStats() | Network since the last call: send/receive kbps, loss, jitter, RTT, codec, concealed audio %. |
getUsers() | Everyone else in the room, as RoomUser[]. |
startScreenShare(options?) / stopScreenShare() | Share my screen on a track of its own, next to the camera. See screen sharing. |
on(event, cb) / off(event, cb?) | Subscribe / unsubscribe. on returns an unsubscribe function. |
| Property | Description |
|---|---|
connectionState | 'disconnected' | 'connecting' | 'connected' | 'reconnecting' |
userId | My user id (the token's sub). |
isPublishing | Whether I am on stage. |
audioOptions | Current audio options. |
Events
| Event | Payload | When |
|---|---|---|
userJoined | RoomUser | Someone entered (also once per person already in the room when I join). |
userLeft | userId | Someone left. |
userUpdated | RoomUser | Someone's stage or microphone state changed. |
soundLevels | { [userId]: 0–100 } | Voice level of the others, every soundLevelIntervalMs. Use it for the speaking animation. |
localSoundLevel | 0–100 | My microphone level. |
roomMessage | fromUserId, message, topic | Someone called sendRoomMessage. |
roomAttributesUpdated | changes, attributes, by | The room's attributes changed (also on joining, if it has any). |
positionChanged | userId, position, team | Someone moved or changed team (range voice), up to 5×/s per person. |
memberAttributesUpdated | userId, changes, attributes, by | Someone'sattributes changed (mine too). |
connectionStateChanged | ConnectionState | Connection state changed. |
publishingStateChanged | boolean | I went on or off stage. |
remoteScreenStarted / remoteScreenStopped | userId, stream / userId | Someone started or stopped sharing their screen (screen sharing). |
screenShareEnded | 'stopped' | 'system' | 'no_frames' | 'left' | My screen share ended, and why. |
error | { code, message } | See error codes. Terminal codes end the session; the SDK does not reconnect after them. |
Audio options
| Option | Default | Description |
|---|---|---|
bitrateKbps | 64 | Opus bitrate. Voice doesn't benefit from more than about 64. |
stereo | false | Two channels (music). Voice is mono. |
dtx | false | Silence uses no bandwidth. Saves traffic but can clip the first syllable. |
echoCancellation | true | WebRTC echo cancellation. |
noiseSuppression | true | WebRTC noise suppression. Turn off if your native module already does it. |
autoGainControl | true | WebRTC automatic gain. Turn off if your native module already does it. |
outputGain | 1 | Volume of the people I hear. |
Types
type RoomUser = { userId: string; name: string; publishing: boolean; micOpen: boolean; attributes: Record<string, string>; position: { x, y, z } | null; team: string | null }
type NetworkStats = {
sendKbps, receiveKbps, sendLossPct, receiveLossPct, jitterMs, rttMs: number | null
codec: string | null
concealedPct: number | null // % of received audio that was concealed (loss)
listening: number // how many voices I am receiving
}
type AudioHooks = {
prepare?(options): Promise<void> | void // before connecting: audio session, route
afterConnect?(options): Promise<void> | void // native processing needs WebRTC up
release?(): Promise<void> | void // on leaving
}
Video (camera) BETA
The same room carries video. Each person's camera is published on a connection of its own (like the microphone), so turning it on never disturbs the audio. Defaults follow the 540p live preset: 540×960, 15 fps, 1,200 kbps, H.264 (hardware encoder on almost every phone). When the network drops, WebRTC lowers resolution and frame rate gradually.
await voice.startCamera() // or startCamera({ width: 720, height: 1280, maxBitrateKbps: 1500 })
voice.on('localVideoChanged', (stream) => showPreview(stream))
voice.on('videoTrackChanged', (userId, stream) => showTile(userId, stream)) // stream null = gone
// React Native
<RTCView streamURL={stream.toURL()} objectFit="cover" mirror={isMe} />
// browser
videoElement.srcObject = stream
| Method / property | Description |
|---|---|
startCamera(options?) | Turns the camera on and publishes it. Options: facing ('user' | 'environment'), width, height, fps, maxBitrateKbps, codec ('h264' | 'vp8' | 'auto'). |
stopCamera() | Stops publishing video. The microphone is not affected. |
suspendCamera() / resumeCamera(options?) | For the app going to the background: releases the camera device but keeps the publication (viewers get cameraOn: false); resumeCamera opens the camera again on the same connection and sends a keyframe at once. No new connection and no new subscriptions, so the picture is back in a fraction of a second on both sides, where stopCamera + startCamera cost seconds of black. isCameraSuspended tells the state. After resuming, the SDK checks that the new frames are really encoded and sent within 1.5 s (tuning cameraResumeCheckMs); if not, it publishes the camera again from scratch. Event cameraResumed { ok, ms, republished, framesEncoded, pcState, replaceError } reports it. |
muteCamera(muted) | Closes the camera without leaving: zero packets are sent and everyone receives cameraOn: false, so they show the avatar instead of a frozen frame. |
switchCamera() | Front ↔ back, without renegotiating. |
setVideoQuality(userId, 'high' | 'low') | Which simulcast layer to receive from someone, only for me. |
getVideoStats() | Size, fps, kbps and loss of my camera and of each camera I receive. See video stats. |
setCameraEffects(names) | Frame processors applied before the encoder, e.g. [IlorbFx.PROCESSOR]. |
getVideoStream(userId) | Someone's camera stream, or null. |
setVideoReceiving(userId, enabled) | Watch or stop watching one person's camera, only for me. Off cancels the subscription after a short hold (10 s in the foreground, 60 s in the background — switching it on again within it shows the picture at once, e.g. leaving the live screen and coming back): then nothing arrives and nothing is billed. In big rooms, subscribe only to the tiles on screen. |
isVideoWaiting(userId), event videoWaiting(userId, waiting, waitedMs) | Someone's camera shows its last frame while a new one is on its way (back from the background, or the connection being rebuilt). Keep the last frame on screen and show a small loading sign over it while waiting is true — never a black screen. |
localVideoStream, isCameraOn | My preview and whether my camera is on. |
RoomUser gains camera (has a camera published) and cameraOn (published and not closed). Option autoSubscribeVideo (default true) receives every camera automatically; set it to false in large rooms and call setVideoReceiving for what is visible.
Billing: video rooms are measured the same way (participant-minutes), and camera-minutes are recorded separately for each room. Video pricing will be announced separately. Effects before the encoder (filters, beauty, background) come from ILORB FX through setCameraEffects.
Simulcast & quality per person BETA
With video.simulcast the camera is sent in two sizes: the full one (sendSize / maxBitrateKbps) and one with half the width at about a quarter of the bitrate. Each viewer picks, only for themselves, which one to receive: 'low' for thumbnails and grids, 'high' for the highlighted tile. Without enough bandwidth the server drops to the small one by itself. It costs the publisher more upload and saves download for every viewer. Tested in Chrome (H.264 and VP8); not yet on real phones.
const voice = new VoiceClient({
serverUrl: 'https://api-signal.ilorb.app',
video: { width: 720, height: 1280, sendSize: { width: 540, height: 960 }, simulcast: true },
})
voice.setVideoQuality('ana', 'high') // the big tile
voice.setVideoQuality('bruno', 'low') // a thumbnail
setVideoQuality(userId, quality) only has an effect if the publisher turned simulcast on.
Video stats & events BETA
voice.on('remoteVideoFirstFrame', (userId, w, h) => swapAvatarForVideo(userId)) // first frame arrived
voice.on('remoteVideoSizeChanged', (userId, w, h) => relayout(userId, w, h)) // layer change, rotation
voice.on('localVideoFirstFrame', (w, h) => hideSpinner())
const { local, remote } = await voice.getVideoStats()
// local: { width, height, fps, kbps, limitation, layers: [{ rid, width, height, kbps, active }] }
// remote: { [userId]: { width, height, fps, kbps, lossPct, quality: 'high' | 'low', frozen } }
kbps and fps are measured since the previous call; limitation says why the encoder is sending less (bandwidth, cpu…).
Poor network: audio only BETA
With lowNetworkVideo: 'audio-only', when someone's camera freezes for me (under 3 fps or over 15% loss for about 6 s), the SDK stops receiving that camera and the voice carries on. It tries again after 20 s, then 40, 80 and 120 s. Only my side is affected; the others keep receiving.
const voice = new VoiceClient({ serverUrl, lowNetworkVideo: 'audio-only' })
voice.on('videoPausedForNetwork', (userId, paused) => showWeakNetworkBadge(userId, paused))
Device table: quality per phone BETA
Your app only says the account's quality (the preset); the SDK caps it by what this phone can publish, before the camera goes live. The answer comes from the server's device table, inside the device profile (settings.video): the device class from the hardware (weak → 540p 24 fps one layer, medium → 720p, good → 1080p), then a measured verdict per phone model from real sessions (a model whose camera ran at low fps, was CPU-limited, went into light mode or ran the effects slowly is capped one step below the size it was sending; a model that proved healthy at a size is allowed that size), then the operator's rows. The SDK never raises what you asked for, only lowers it.
// before going live (the preview): the account preset, already capped for this phone
const video = await voice.videoForPreset('preset1080p')
await voice.startCamera(video) // startCamera caps by itself too
voice.on('videoCapped', ({ caps, capped, requested, effective }) => log(caps.reason, capped))
voice.videoCaps // { caps: { maxPreset, maxFps, maxKbps, codec, simulcast, effectsMaxSide, gpuTier, reason }, requested, effective, capped }
startCamera waits up to 1.5 s for the first answer of the table (the last answer is kept on the device and applies at once). A table change during the live re-caps the camera without republishing. capVideo(options, caps) and videoForPreset(preset, { caps }) are exported for custom flows. Turn it off with the tuning key videoDeviceCaps: false.
Data synced to the frame (SEI) BETA
What other SDKs call SEI or video metadata: a small payload (a gift id, a karaoke line, a game state) that viewers receive at the frame it was sent with, not whenever the network delivers it. The video itself is never changed, so every decoder keeps working.
// publisher — frameData: true stamps the camera from the first startCamera
const voice = new VoiceClient({ serverUrl, frameData: true })
voice.sendFrameData({ gift: 'rose', x: 0.4 }) // text or JSON, ≤ 4096 chars, ≤ 60/s; false = not sent
// viewer (browser): register the <video> showing that person, then listen
voice.setVideoElement('ana', videoElement)
voice.on('frameData', (userId, data, info) => showGift(JSON.parse(data))) // info.mode: 'frame' | 'timed'
The publisher stamps the payload with the RTP timestamp of the next encoded frame (a pass-through RTCRtpScriptTransform); the room relays it; the viewer releases it when that frame is presented (requestVideoFrameCallback), holding the video a little longer in its jitter buffer (up to 250 ms) when the data would otherwise arrive late. Measured in Chrome through the SFU: 98–100% of the payloads fire on their own frame after a ~3 s warm-up (a plain room message: 3%). Where the publisher cannot stamp (React Native today) or the viewer has no element, the payload is released by the viewer's measured video delay (info.mode: 'timed', about ±1–2 frames). frameDataStats shows the counters.
End-to-end encryption PREVIEW BROWSER REACT NATIVE
Development server only for now (protocol 15). Browser and React Native (Android proven against a browser in the same room; iOS builds, not yet run on an iPhone); Kotlin and Swift are next. Every audio and video frame is encrypted on the sender's device and decrypted only by the other participants. The media still goes through our SFU — simulcast layer switches and keyframe requests keep working — but the server never has a key and never sees a decoded sample.
// 1) a secret your server gives every participant of the room
const voice = new VoiceClient({ serverUrl, e2ee: { key: roomSecretFromYourServer } })
// 2) your key service, per key index (0–255)
new VoiceClient({ serverUrl, e2ee: { keyProvider: { getKey: ({ roomId, keyIndex }) => fetchKey(roomId, keyIndex) } } })
// 3) no app secret: a random room key exchanged between the participants (ECDH), rotated when someone leaves
new VoiceClient({ serverUrl, e2ee: { keyProvider: 'exchange' } }) // + key: '…' = the server cannot swap the public keys
voice.on('e2eeDecryptionFailed', ({ userId, kind, reason, keyIndex }) => …) // auth_failed | missing_key | not_encrypted
voice.on('e2eeKeyChanged', ({ keyIndex, reason }) => …)
await voice.rotateE2eeKey() // shared key: ratchet (the others follow by themselves) · exchange: only the leader
await voice.setE2eeKey(newSecret) // shared key: every participant sets the same new secret
voice.getE2eeStats() // mode, engine, key index, frames encrypted / decrypted / dropped by reason, µs per frame, leader, fingerprints
// React Native: hand the SDK the native frame cryptor of @livekit/react-native-webrtc (nothing else changes)
import { MediaStream, RTCFrameCryptorFactory, RTCPeerConnection, mediaDevices } from '@livekit/react-native-webrtc'
new VoiceClient({ serverUrl, webrtc: { RTCPeerConnection, mediaDevices, MediaStream, RTCFrameCryptorFactory }, e2ee: { key } })
- Format. Per encoded frame, the format of WebRTC's native FrameCryptor (webrtc-sdk), so browsers and phones decrypt each other: AES-GCM-128 with a 96-bit IV; the codec header stays clear and is authenticated (Opus: 1 byte; VP8: the 3/10-byte frame tag; H.264: the NAL headers up to the first slice, the encrypted part escaped so it never looks like a start code). Trailer: IV, IV length (12), key index — 30 bytes per frame with the tag (+12 kbps for 50 audio frames/s, +7.2 kbps for 30 video fps). The browser encrypts in an encoded-transform worker (
RTCRtpScriptTransform), React Native in WebRTC's native FrameCryptor (getE2eeStats().engine:'web'/'native'; the native engine reports each track's state, not per-frame counters). - Keys. Key material per index: your secret (shared key), what your key service returns (
keyProvider) or the leader's random key (exchange). Frame key = HKDF-SHA-256(material, salt"ilorb-e2ee/v2/" + roomId, info 128 zero bytes, 16 bytes); rotation ratchets the material (index + 1, HKDF to 32 bytes), receivers ratchet on their own up to 16 steps (React Native installs the next 16 ahead). Exchange: each participant has a P-256 key pair; the leader (lowest user id holding the key) sends the room key wrapped with an AES-GCM key derived from the pairwise ECDH secret (and yourkey, if given) through the room's WebSocket. When someone leaves, the leader makes a new key for the ones who stayed; the previous keys are kept briefly so frames in flight still decrypt. ComparegetE2eeStats().fingerprintout of band to rule out a man in the middle when you do not give akey. - What cannot work. Cloud mix, recording (room and per person), RTMP, the cloud player and content moderation need to see the media. Joining a room where any of them is on fails with
e2ee_conflict; while anyone in the room encrypts, the server refuses to start them (HTTP 409e2ee_active) and the room state listse2ee.users.e2ee: { allowConflicts: true }joins anyway (those features get undecodable data; moderation samples are never sent). The separate music track is not available (the music stays on the encrypted mic). - Never in clear. A frame that does not decrypt is dropped, never played (a wrong key hears silence and sees no video). A runtime that cannot encrypt fails the join with
e2ee_unsupportedinstead of sending in clear: a browser without encoded transforms, React Native withoutRTCFrameCryptorFactoryinwebrtc, andkeyProvider: 'exchange'on React Native (it needs WebCrypto ECDH in JS, which Hermes lacks — usekeyor akeyProviderthere). The Kotlin and Swift SDKs do not support E2EE yet. - Cost. Measured in Chrome (2 peers, mic + 640×480 camera, VP8 and H.264): ~30–70 µs to encrypt and ~25–55 µs to decrypt a frame (the added latency, well under a millisecond); the browser's CPU with and without E2EE differed by −1 to +4 points of one core across three runs (run-to-run noise); the same time to the first video frame.
Screen sharing BETA
The screen goes on a track of its own ({roomId}-{userId}-screen), separate from the camera: you can share the screen with the camera on, and viewers receive both. Protocol 7. Tested end to end in Chrome; React Native (Android and iOS) still needs testing on real phones.
- No simulcast: viewers always get the full size, so text stays readable.
contentHint = 'detail'anddegradationPreference = 'maintain-resolution': on a bad network the frame rate drops, not the sharpness.- Defaults: 15 fps, up to 2,500 kbps, the camera's codec.
Sharing
// Browser: call it straight from the click (getDisplayMedia needs a user gesture)
button.onclick = async () => {
const stream = await voice.startScreenShare() // preview: video.srcObject = stream
}
await voice.stopScreenShare()
voice.on('screenShareEnded', (reason) => …) // 'stopped' | 'system' | 'no_frames' | 'left'
voice.isScreenSharing // boolean
voice.localScreenStream // preview stream or null
Where the track comes from, in this order: startScreenShare({ track, stream }) (a ready video track) → startScreenShare({ capture }) (capture for this call only) → new VoiceClient({ screenCapture }) (the client's default) → navigator.mediaDevices.getDisplayMedia (browser). A track that came from your app is yours: on stop the SDK unpublishes it but does not stop it — unless you pass stopTrackOnEnd: true (what you want in React Native: any end, including a stop from the system UI, then also ends the capture). A track the SDK captured itself, it stops.
React Native with @ilorb/signal-react-native-screen (iOS: the ReplayKit extension, created by its Expo plugin):
import { ScreenCapturePickerView } from '@livekit/react-native-webrtc'
import { IlorbScreen } from '@ilorb/signal-react-native-screen'
const picker = useRef(null) // <ScreenCapturePickerView ref={picker} /> somewhere on screen
await voice.startScreenShare({
capture: () => IlorbScreen.start({ pickerRef: picker }), // a refused consent rejects: screen_share_denied
stopTrackOnEnd: true, // the SDK ends the capture on every end
})
| Option | Default | Description |
|---|---|---|
track, stream | — | A ready video track (React Native: the track from IlorbScreen.start()). |
capture | — | Capture function for this call only. |
fps | 15 | Frame rate. |
maxBitrateKbps | 2500 | Bitrate cap. |
codec | the camera's | 'h264' | 'vp8' | 'auto' |
noFramesTimeoutMs | 15000 | No frame in this time → error screen_share_no_frames and stop. |
stopTrackOnEnd | false | Also stop and release the track / capture() result when the share ends for any reason (app, system UI, no frames, leaving the room). React Native: true. |
It ends by itself when the person stops it from the system bar or button (track ended → screenShareEnded('system')) and when no frame arrives in 15 s (iOS: the person cancelled the system picker → error screen_share_no_frames and screenShareEnded('no_frames')). On a network drop the capture keeps going and the screen is published again on reconnect, without asking for permission again.
Errors from startScreenShare (err.code, not terminal): not_in_room, screen_share_unsupported (no getDisplayMedia and no capture given), screen_share_denied (the person refused), screen_share_failed.
Watching
voice.on('remoteScreenStarted', (userId, stream) => { screenEl.srcObject = stream }) // RN: <RTCView streamURL={stream.toURL()} />
voice.on('remoteScreenStopped', (userId) => { screenEl.srcObject = null })
voice.getUsers() // RoomUser.screenOn: true while sharing
voice.getScreenStream(userId) // the screen stream or null (the camera stays in getVideoStream)
voice.setScreenReceiving(userId, false) // stop receiving it (cancels the subscription)
new VoiceClient({ autoSubscribeScreen: false }) receives nobody's screen until setScreenReceiving(userId, true). A shared screen counts as video in usage (video minutes and the traffic of whoever receives it).
PK — linked rooms BETA
A PK (host vs host) links two rooms: everyone in each room sees and hears the stage of the other room (whoever is publishing), with no mixer — zero added delay, each app draws its own layout (1v1, 2v2…), and voice levels arrive per person. The audience of each room stays in its own room. Your server decides who plays whom (invites, random match, score, timer) and links/unlinks the rooms:
{ "room": "otherRoomId" }{ "room": "otherRoomId" }{ "topic": "pk", "message": {…} }Both rooms must be open (409 otherwise) and belong to the same project. broadcast sends a message from your server to everyone in the room (score, "PK started", winner) — it arrives as roomMessage with fromUserId: "server". GET /v1/rooms/{roomId} lists the links and the remote stage.
voice.on('roomLinked', (roomId, users) => showPkLayout(users)) // the other room's stage
voice.on('videoTrackChanged', (userId, stream) => drawTile(userId, stream)) // same as local people
voice.on('roomUnlinked', (roomId) => backToSolo())
// RoomUser.roomId tells who is from the other room; getLinkedRooms() lists the links
Everything you do with local people works with the linked stage: setVideoReceiving, muteUser, setUserVolume. Traffic from the other room counts in the room of whoever receives it. Coming next: a mixed output (one composed stream) for very large audiences and RTMP. Cloud recording is in beta.
PK battle in the SDK PREVIEW
voice.pk runs the whole battle for you — no server code needed: one host invites a host of another room, the other accepts, the rooms link, a timer runs, gifts add points to a side, the result comes with a punishment timer, then the rooms unlink by themselves. The state is the room attribute pk:state in both rooms (written only by the room), so whoever joins late gets the current score and timers with the room. Timers are in server time: remainingMs() gives the same countdown in both rooms.
// host A (publishing, or an admin token)
const { pkId } = await voice.pk.invite('room-b', 'host-b', { durationSec: 180, punishSec: 60, scoring: 'server' })
// host B
voice.pk.on('invited', (inv) => ask(inv) ? voice.pk.accept(inv.pkId) : voice.pk.reject(inv.pkId))
// everyone, in both rooms
voice.pk.on('state', (s) => render(s)) // s.phase 'battle' | 'punish' | 'ended', s.sides[0|1].score, s.winner, s.reason
voice.pk.remainingMs() // the current phase's countdown
voice.pk.sides() // { mine, other }: my room's host and the other one
voice.pk.on('gift', (g) => playGift(g.giftId)) // a gift in either room
await voice.pk.end() // a PK host ends it early
Points. With scoring: 'server' (the default) only your server adds points, after it charges the gift: POST /v1/rooms/{roomId}/pk { "action": "score", "points": 10, "to"?: "roomId", "giftId"?: "rose", "userId"?: "buyer" } (Basic appId:appSecret; to defaults to this room). With scoring: 'clients' viewers add points with voice.pk.sendGift({ giftId, points, to? }) — only for free gifts. { "action": "state" } reads the PK, { "action": "end" } ends it.
Edge cases the room handles: a host whose connection drops is waited for 15 s (a reconnection keeps the battle); after that the other side wins (reason: 'host_left'); an unanswered invite expires in 20 s; a busy room refuses (pk_busy); points after the battle are refused (pk_not_running); pk:* attributes cannot be written by clients or /attrs. Errors: pk_not_host, pk_busy, pk_pending, pk_invite_not_found, pk_host_absent, pk_scoring_server, pk_invalid, sfu_mismatch. Examples: examples/web/pages/pk, the React Native PK battle screen.
Room & member attributes BETA
Small key → text values kept by the room and sent to everyone: the room's (topic, cover, who is on each seat, the score) and each person's own (role, level, "hand raised"). Changes reach everyone as events with only what changed. Protocol 9.
// the room's: everyone receives roomAttributesUpdated
await voice.setRoomAttributes({ seat1: 'ana', topic: 'Friday karaoke' })
await voice.deleteRoomAttributes(['seat1'])
voice.getRoomAttributes() // { topic: 'Friday karaoke' }
// mine: everyone receives memberAttributesUpdated; gone when I leave, sent again by the SDK after a reconnect
await voice.setMyAttributes({ hand: 'up', level: '12' })
await voice.setMyAttributes({ hand: null }) // null deletes
voice.getUsers()[0].attributes // someone else's
voice.on('roomAttributesUpdated', (changes, attributes, by) => render(attributes)) // changes: only what changed, null = deleted
voice.on('memberAttributesUpdated', (userId, changes, attributes, by) => …) // by: userId, or '__server'
The promises resolve when the room confirms. A refusal rejects the promise with err.code and is not a session error: attrs_invalid (bad key or non-text value), attrs_too_large, attrs_denied (room attributes are locked to admins). One call changes everything or nothing.
| Item | Rule |
|---|---|
| Key | [0-9A-Za-z_.:-], 1–64 characters |
| Value | Text, up to 1,024 characters (send JSON as a string if you need structure) |
| Room | Up to 32 keys and 16,384 characters (keys + values). Kept by the room, even while it is empty, until it is closed. |
| Each person | Up to 16 keys and 2,048 characters. Only the person (or your server) changes them; they are removed when the person leaves. |
| Who changes the room's | Everyone by default. Your server can lock them with writers: 'admin' (POST …/attrs): then only your server and tokens created with admin: true (createUserToken) can change them. |
| Joining | Whoever joins gets the room's and everyone's attributes right away (roomAttributesUpdated with by: '__server' if the room has any). |
Room attributes are not for things that change many times per second (use sendRoomMessage), and they are not a database: closing the room deletes them.
Stage seats (voice.stage) BETA
The mic seats of a voice room, built into the SDK and enforced by the room (protocol 13): a member takes only a free, unlocked seat for themselves and leaves their own; a muted seat cannot open its mic; with the stage on, only someone on a seat publishes the microphone. The seats live in the room attributes (stage.seats, seat.<n>), so late joiners get them with the room.
voice.on('seatsChanged', (seats) => render(seats)) // [{ index, user, locked, muted }]
await voice.stage.takeSeat() // first free seat (or takeSeat(3)); publishes the mic
await voice.stage.leaveSeat() // stops publishing and frees the seat
// host (a token with admin: true) or your server (POST /v1/rooms/{roomId}/attrs)
await voice.stage.setSeatCount(8) // turns the stage on
await voice.stage.muteSeat(2, true) // whoever sits there cannot unmute
await voice.stage.removeFromSeat(2) // takes them off the stage (their mic stops)
await voice.stage.lockSeat(5, true) // nobody can take seat 5
Someone on a seat whose connection drops keeps the seat for a short grace period (a reconnection); a kick or a closed room frees it at once. Demo: examples/voice-room-demo.
Range voice & teams BETA
For games and virtual spaces: each person has a position and, optionally, a team. You hear people near you louder, people far away not at all, and your team anywhere. The room only relays positions and teams; volume, stereo pan and who to receive are decided on the device. Protocol 10.
voice.setPosition({ x: 12, y: 0, z: -4 }, { right: { x: 1, y: 0, z: 0 } }) // your game's units; right = the vector to my right (pan)
voice.setAudioRange(30) // hear up to 30 units away (default 100)
voice.setTeam('red') // or null
voice.setRangeMode('world') // 'off' (default) | 'world' | 'team'
voice.on('positionChanged', (userId, position, team) => moveAvatar(userId, position))
voice.getRangeInfo() // { mode, radius, users: [{ userId, distance, gain, pan, audible, subscribed }] }
| Mode | What you hear |
|---|---|
'off' (default) | The normal room: everyone at full volume. Positions are still relayed, so you can draw the map. |
'world' | Everyone within the range, with volume 1 − distance / range and stereo pan by direction. People who never sent a position are heard normally. |
'team' | Only people with your team, at any distance, at full volume (no team = nobody). |
- Out of range = unsubscribed. Someone farther than the range × 1.15 is unsubscribed: nothing arrives and nothing is billed. They are subscribed again when they come back inside the range. The gap between the two (hysteresis) keeps someone on the edge from being subscribed and unsubscribed all the time. Coming back takes about 0.5–1 s, like
muteUser. - Rate. Call
setPositionas often as you like (every frame is fine): it reaches the others at most 5 times per second, and the latest position always arrives. - Pan works in the browser (WebAudio) and, in beta, on React Native Android and the Kotlin SDK through the SDK's own stereo output (see spatial audio). React Native iOS and Swift get the volume by distance; their stereo pan is written but not yet tested on an iPhone.
- Position, team, range and mode are kept across reconnects and rooms.
RoomUser.position/teamshow everyone else's. Your ownsetUserVolumemultiplies the distance volume.
Speech detector & spatial audio BETA ANDROID
Our own speech detector runs in the native audio core (@ilorb/signal-react-native-audio; the Kotlin SDK runs the same C code; Swift and React Native iOS are written but not tested on an iPhone). Every 10 ms it weighs the energy above the noise floor, the pitch (80–400 Hz), the speech-band shape and how the level moves between syllables, plus the noise AI's voice probability; a music detector (held pitch, steady spectral peaks, a beat) keeps songs from opening it. Its send gate sends exact silence while nobody speaks, so Opus DTX stops sending.
const voice = new VoiceClient({ serverUrl, webrtc, vad: true }) // or { gate: false }: detector and events only
voice.on('localSpeakingChanged', (speaking) => showTalking(speaking))
await voice.setVad(true) // change it later
await voice.getVadStats() // { enabled, gate, available, speaking, speechPct, gatedPct, openPct, onsets, frames, levelDb, … }
| Open mic in a quiet room (Android emulator, development server) | On the wire |
|---|---|
| Detector off, DTX off | 76.3 kbps |
| Detector on, DTX off (the SDK caps the silent sender at 6 kbps after 1 s) | 23.2 kbps (−70%) |
| Detector on, DTX on | 3.2 kbps (−96%) |
On recordings: the gate stays open on 97% of loud speech frames and loses 1.46% of the speech energy (word starts); all 11 music clips stay at or under 12% open. Weak spots: dog barks, a café with voices, and speech over a song (the voice loses more there). The default is off (tuning.vad): the device table can turn it on per phone model from the server. Cost: 17–56 µs per 10 ms frame on the emulator.
Spatial audio on phones. WebRTC plays every received voice as one mono stream, so a pan cannot live inside it. With range mode 'world' or 'team', React Native Android (and the Kotlin SDK) taps each received voice, mixes them in our C mixer with their distance gain and an equal-power pan, and plays the stereo mix on the SDK's own output, while WebRTC keeps running muted so the echo canceller still has its reference. Measured on the emulator: hard left/right +100 / −100 dB between the channels, half-left 7.7 dB (the law says 7.66), 0 underruns. Range mode 'off' gives the output back to WebRTC. await voice.getSpatialStats() reports the mixer. Not heard on a real phone yet (headphones, loudspeaker echo, Bluetooth).
Spatial audio in the browser. The browser uses the same pan law as the phones' C mixer (equal power, unity in the center, +3 dB on one side at the ends; not WebAudio's StereoPannerNode, which is 3 dB quieter in the center), so a voice sounds the same on the web and on a phone. await voice.getSpatialStats() works there too: { platform: 'web', active, voices, leftDb, rightDb, limited, tracks: [{ userId, pan, gain, gl, gr, leftDb, rightDb }] }, levels in dBFS measured on the real WebAudio output over the last ~0.7 s. Measured against the development server (two Chrome peers, a 440 Hz tone): hard left/right ±105 dB between the channels, in front 0 dB, half-left 7.7 dB (law 7.66), distance 30 vs 60 = 0.7/0.4, in front at 60 exactly −7.96 dB under the reference. Demo: examples/web/pages/range-audio.
Noisy place warning BETA ANDROID
Tells the person on the mic that the place is too noisy for their voice ("move somewhere quieter") before the room hears it. The native audio core (@ilorb/signal-react-native-audio) measures the voice against the background on the raw microphone, before the noise AI, over 5 s windows: 'poor' = the AI removes the noise but the voice may sound muffled or robotic, 'bad' = the noise covers the voice. With the noise AI's voice probability it measures the speech-to-noise ratio; without the AI it mostly flags a loud background. The SDK only warns while your mic is open, stays quiet with music on the mic (Android: the sound classifier), and does not repeat the same warning within the cooldown.
const voice = new VoiceClient({ serverUrl, webrtc, noiseWarning: true }) // or { cooldownMs, snrPoorDb, snrBadDb, noisePoorDbfs, noiseBadDbfs }
voice.on('noiseWarning', (w) => w.level === 'ok' ? hideBanner() : showBanner(w.message))
// w = { level: 'ok' | 'poor' | 'bad', reason: 'noise' | 'quiet' | 'music' | 'micClosed', quality, message }
// other languages: noiseWarningMessage(w.level, 'pt', w.quality?.noiseKind)
await voice.setNoiseWarning(false) // change it later
await voice.getNoiseQuality() // { enabled, available, level, snrDb, noiseDbfs, voiceDbfs, speaking, voiceAi, music, noiseKind, badSeconds, measuring }
Timing (defaults, server-tunable): the level rises after ~7 bad seconds in 10 and falls after ~8 good ones; a rise must then hold 4 s with no music (tuning.noiseWarningConfirmMs); the same warning waits 5 min (noiseWarningCooldownMs); thresholds noiseSnrPoorDb 6, noiseSnrBadDb 0, noisePoorDbfs −30, noiseBadDbfs −20. Measured on the Android emulator (speech over a loud fan played into the mic): warned after 14–16 s, cleared 10 s after the place went quiet; a quiet room, clean speech and music gave no warning. The default is off (tuning.noiseWarning). iOS: written (no sound classifier, so no music check and no noise name), not tested on an iPhone.
Karaoke PREVIEW BROWSER
Lyrics synced on the singer and on every viewer, real-time pitch scoring, lead + chorus singing, a song catalogue and a pitch/timbre voice changer. Web first, on our development server; the platform-neutral core (packages/client/src/karaoke.ts: LRC parser, melody, scorer, sync, chorus clock) is shared with React Native, which still needs the native pitch tap.
import { VoiceClient } from '@ilorb/signal'
import { ilorbWebRTC, ilorbAudioHooks, IlorbAudio, IlorbKaraoke, KaraokeCatalogue } from '@ilorb/signal-web-fx'
const voice = new VoiceClient({ serverUrl, webrtc: ilorbWebRTC(), hooks: ilorbAudioHooks() })
const catalogue = new KaraokeCatalogue({ serverUrl, token: userToken })
const karaoke = new IlorbKaraoke(voice, { catalogue }) // BEFORE startPublishing: it stamps the mic sender
await voice.joinRoom(room, token)
await voice.startPublishing()
karaoke.on('line', ({ index, line }) => …) // line: { startMs, endMs, text, words } | null (gap)
karaoke.on('word', ({ line, index, word, progress }) => …)
karaoke.on('pitch', (p) => …) // { posMs, hz, midi, refMidi, cents, credit, line } every 10 ms
karaoke.on('lineScore', (s) => …) // { userId, index, score 0–100, grade S/A/B/C/D, … }
karaoke.on('score', (t) => …) // { userId, score, grade, lines, final }
karaoke.on('state', (e) => …) // { state: playing | paused | stopped, role, singer, song }
// singer
await karaoke.loadSong('twinkle-twinkle') // from the catalogue — or load({ track, lrc, melody, songId })
await karaoke.start(); await karaoke.pause(); await karaoke.resume(); await karaoke.seek(ms); await karaoke.stop()
await karaoke.setLatencyCompensation('auto') // or ms
// viewer: nothing to call (it follows whoever sings), or karaoke.follow(userId)
// chorus (someone publishing, e.g. on a seat)
await karaoke.joinChorus(leadUserId, { leadVolume: 0.25 }); karaoke.leaveChorus()
await IlorbAudio.setVoiceMorph('female') // 'male' | 'child' | 'giant' | 'anime' | 'whisper' | 'off', or { semitones, targetHz, formant, tilt, breath, whisper, mix }
- Viewer sync. The singer's mic sender reports the RTP timestamp of every encoded frame and sends a beacon on the room-message topic
ilorb.karaokeabout 4 times a second; each viewer maps the RTP timestamp of the audio it is playing to the song position, so network delay and the jitter buffer cancel out. Measured with three browsers on the development server: the line event lands 9–28 ms before the viewer hears the line (median), worst 23–38 ms. Without encoded transforms (or with E2EE on) it falls back to a timed mode. - Scoring. YIN pitch every 10 ms on the singer's raw voice (before voice effects), against the reference melody: full credit within 35 cents, none from 150 cents, octave-folded. A line's score is the mean credit of its frames; line scores go to the room so viewers can show them.
- Chorus. The server only forwards media, so every chorus member plays the song locally on the lead's clock (NTP-style clock sync over room messages). Measured at a listener: chorus voice vs the lead's music 29–43 ms (median); singing to the lead's audio as it arrives would be 203–395 ms. Use headphones.
- Voice morph is a source-filter vocoder, not a neural network: pitch and timbre move independently, 20.7 ms of added latency (0 when off), ~0.3% of a core inside Chrome's audio worklet.
- Not yet: React Native / Kotlin / Swift engines, the voice morph in C, a latency calibration screen, a licensed song catalogue. The catalogue holds only content we may use (public-domain melodies and our own songs).
The catalogue's REST API is under Karaoke catalogue; the console lists your project's songs under Develop → Karaoke catalogue.
Background & economy
Most of the cost of a voice room is audio flowing to people who aren't listening. The SDK is built to send and receive only what is heard:
- Muted microphone sends nothing.
muteMicrophone(true)keeps you on stage but sends zero packets; unmuting is instant. It stays at zero after a reconnect or a republish while muted — automatic, nothing to call. - Muting someone unsubscribes.
muteUserstops receiving that voice at the network level, not just the volume. - Long background? Stop receiving. Call
setReceiving(false)after a few minutes in the background, andsetReceiving(true)on return. - Closed camera sends nothing.
muteCamera(true)stops the frames at once (viewers getcameraOn: falseand show the avatar);stopCamera()also turns the camera off. In the background, callsuspendCamera()andresumeCamera()on return (notstopCamera/startCamera: those rebuild the connection and every viewer subscribes again). - Android: run a foreground service while live. Without one, Android 15 / HyperOS freezes a background app and cuts its network after a few seconds, so the room is lost and the return costs a reconnection (~3–5 s).
IlorbLiveService.start({ title, text })from@ilorb/signal-react-native-audio(type microphone|mediaPlayback, start it from the foreground) andIlorbLiveService.stop()when leaving; or keep your own media foreground service running. - React Native Android: apply the SDK's react-native-webrtc patch.
packages/react-native-audio/patches/@livekit+react-native-webrtc+144.1.2.patch(patch-package format) lets the SDK set the ICE ping interval of the selected pair; without it a join or reconnect can wait up to 2.5 s for the connection to be nominated. - Send the size you show. A video tile is small: capture big and send small with
sendSize(e.g. capture 720×1280, send 360×640 at ~400 kbps). On a phone screen it looks the same and costs about a third. - Watch only who is on screen.
setVideoReceiving(userId, false)unsubscribes a camera — it is not delivered and not billed as traffic. In big rooms, useautoSubscribeVideo: falseand subscribe to the visible tiles. - Coming back from the background. Call
resume(). If the connection survived, voice is back in about 0.3 s; there is no need to leave and join again (that takes seconds).
AppState.addEventListener('change', (s) => {
if (s === 'active') voice.resume()
})
Pre-call test BETA
Check the network, the microphone, the speaker and the camera before joining. It works outside a room, without a token. The simplest way is one call, runPrecheck, which runs every test and returns one verdict, each check's details and advice you can show as is (English) or translate by code:
import { runPrecheck, testCamera } from '@ilorb/signal'
const r = await runPrecheck({
serverUrl, // network test (omit to skip it)
camera: { onStream: (s) => setPreview(s) }, // false for a voice-only app
onProgress: (name, check) => render(name, check.status), // 'running' → 'pass' | 'warn' | 'fail' | 'skipped'
})
r.verdict // 'ready' | 'degraded' | 'not-ready'
r.checks // { network, microphone, speaker, camera }: { status, result, advice, durationMs }
r.advice // [{ check, code: 'camera-too-dark', severity: 'warn', message }] — failures first
const cam = await testCamera({ facing: 'user' })
// { ok, opened, frames, fps, width, height, requested, brightness, freezes, longestGapMs, problems, source }
testCamera opens the camera, waits for the first frame, then measures the frames and the fps and size actually delivered (versus what was asked), freezes (a gap of 600 ms or more), and the brightness (too-dark / too-bright). On Android with @ilorb/signal-react-native-audio a native frame processor sees every frame; in the browser a hidden video element is used; elsewhere (iOS today) WebRTC statistics, without brightness. On Android testSpeaker plays a real tone and speakerTest() also returns the media volume (0 → advice speaker-muted). Advice codes: network-bad, network-poor, network-no-video, network-unreachable, mic-silent, mic-permission, mic-missing, mic-busy, speaker-no-output, speaker-muted, speaker-low, speaker-blocked, camera-permission, camera-missing, camera-busy, camera-open-failed, camera-no-frames, camera-frozen, camera-low-fps, camera-low-resolution, camera-too-dark, camera-too-bright. Each test also exists alone:
import { testNetwork, testMicrophone, testSpeaker } from '@ilorb/signal'
const net = await testNetwork('https://api-signal.ilorb.app')
// { rttMs, downKbps, upKbps, quality: 'excellent' | 'good' | 'poor' | 'bad', maxCameras }
const mic = await testMicrophone({ durationMs: 3000 }) // ask the user to speak
// { ok, peak, average } levels 0–1; ok = something above silence
const played = await testSpeaker() // 1 s tone, the user confirms (browser; Android with the native package)
testNetwork measures the round trip to the nearest Cloudflare edge (median of 5) and the download/upload speed against /v1/probe. maxCameras is how many cameras at ~400 kbps fit in the download. testMicrophone uses a local WebRTC loop on the device, so it behaves the same in React Native and in the browser; in React Native pass webrtc as in the VoiceClient.
testNetwork needs the /v1/probe endpoint, which reaches api-signal.ilorb.app with the next server release. Until then it fails with HTTP 404; the microphone and speaker tests work today.Reconnection & diagnostics
When the network drops, the SDK reconnects by itself (attempts at most 4 s apart) and republishes your microphone and camera. If the server notices that your microphone track was lost on the media server, it asks the SDK to republish it automatically. When your app knows the internet is back (React Native: NetInfo), call reconnectNow() to skip the wait.
NetInfo.addEventListener((s) => { if (s.isConnected) voice.reconnectNow() })
const voice = new VoiceClient({
serverUrl: 'https://api-signal.ilorb.app',
webrtc,
device: { brand: Device.brand, model: Device.modelName, os: `android ${Device.osVersion}`, appVersion: '1.8.0' },
})
const info = voice.getConnectionInfo()
// { socket, receive: { connection, ice }, publish, camera, lastError, muteLeaks, republishes }
| Field | Meaning |
|---|---|
socket | Signaling WebSocket: 0 connecting · 1 open · 2 closing · 3 closed · null none. |
receive / publish / camera | Connection and ICE state of the connection that receives the others, of my microphone and of my camera (null when not in use). |
lastError | The last error the SDK saw (code: message). |
muteLeaks | Times the muted microphone was still sending packets and the SDK paused the sender. |
republishes | Times the server asked to republish the microphone because the media server lost the track. |
Per-device diagnostics. Every 30 s (and when leaving) the SDK sends a summary of the connection: what it sends and receives, how many voices, time with no audio, loss, delay, drops and automatic fixes. The console shows it in Develop → Diagnostics, grouped by device model and by person, so you can see which phones have trouble without asking users for logs. Pass device so React Native sessions are grouped by model; turn it off with sendStats: false.
Console: Live
Console → Live lists the rooms open right now. Open one to see each person, refreshed every 10 s, from the latest stats sample their SDK sent: device and app version, microphone open or muted, publishing, send / receive kbps, loss, RTT, concealed audio, reconnects, muted-microphone leaks, republishes and the last error. In red: loss above 5%, receiving nothing while someone is speaking, muted microphone leaking, app version below the minimum, and a call or media volume of 0–2.
Each person also shows the SDK's live blocks of that sample — on Android, iOS and the browser, with no code in your app: Echo now (the canceller running, AEC3 echo return loss / enhancement, the echo meter's likelihood and coupling over the last window), Voice now (microphone level in dBFS, speech level and share of the window with speech, native AGC gain, the noise AI running), Device now (network type and changes, app CPU, thermal state, free memory, battery, power saving), Video now (camera wanted / published, outgoing size, fps, kbps and limitation, light mode, the cameras received: fps, loss, kbps, smallest height, frozen) and Effects (ILORB FX fps, ms per frame, face detection ms, delegate, size, effects on). Fields a platform cannot measure are null (see stats values). In red also: echo heard (likelihood above 0.5), a hot device (thermal 3 or more), a frozen received camera and a camera the app asked for that is not published. With no diag from your app, the SDK's own summary (noise AI, echo canceller, NS/AGC, bitrate, scenario, device profile) is shown as the person's diag.
Volume, audio route, echo canceller and OTA version come from your app, not from the SDK: send them as a room message with the topic diag and the console shows the latest one per person. Any JSON works; keys containing vol, route, echo/aec and ota are shown in their own rows, the rest under "app diag".
voice.sendRoomMessage(JSON.stringify({
version: 112, ota: '2026-10-21.3',
volume: { call: 9, media: 6 }, route: { call: 'speaker' },
echo: { mode: 'hw', residualDb: -32.5 },
}), 'diag')
Other people in the room also receive this message (roomMessage with topic diag); ignore that topic in your UI.
Console → Quality turns the same stats into a 1–5 score per connection (MOS-style, a simplified ITU-T G.107 E-model from round-trip time, loss and concealed audio; a connection that received nothing while others spoke scores 1), by day, by room and by device model, worst first. The formula is shown on the page.
React Native
import { mediaDevices, MediaStream, RTCPeerConnection } from '@livekit/react-native-webrtc'
import { VoiceClient } from '@ilorb/signal'
const voice = new VoiceClient({
serverUrl: 'https://api-signal.ilorb.app',
webrtc: { RTCPeerConnection, mediaDevices, MediaStream },
})
Android needs the RECORD_AUDIO permission (and MODIFY_AUDIO_SETTINGS); iOS needs NSMicrophoneUsageDescription.
Native audio processing (@ilorb/signal-react-native-audio)
Our voice processing on the device, after the system echo canceller. Without the native module in the app, VoiceClient keeps working with the standard WebRTC audio, and every call below returns without doing anything.
import { IlorbAudio } from '@ilorb/signal-react-native-audio'
await IlorbAudio.configure({
noise: 'ai', // AI noise reduction (DeepFilterNet on Android arm64, RNNoise otherwise)
noiseStrengthDb: 24, // 12 soft · 24 medium · 100 no limit
agc: true, clarity: true, highPass: true, howlingSuppression: true,
echoResidual: 'simple', // BETA, Android: lowers the mic when it only hears the others
})
// turn off the WebRTC versions you replaced
await voice.setAudioOptions({ noiseSuppression: false, autoGainControl: false })
await IlorbAudio.setVoiceChanger('robot') // 'off' | 'robot' | 'child' | 'maleToFemale' | 'femaleToMale' | 'monster'
await IlorbAudio.setReverb('concert', 0.3) // 'off' | 'room' | 'studio' | 'concert' | 'church', mix 0..1
// iOS: in-ear monitoring (only with headphones)
const voice = new VoiceClient({ serverUrl, webrtc, hooks: { prepare: () => IlorbAudio.prepareEarMonitoring() } })
await IlorbAudio.setEarMonitoring(true, 0.8)
| Feature | Status | Read back |
|---|---|---|
| AI noise, AGC, clarity, high-pass, howling, mic gain | In production Android and iOS | IlorbAudio.stats() |
Residual echo (echoResidual) | Beta Android, off by default; iOS builds, not yet tested on a device | echoStats(), echoResidualAvailable |
| Voice changer, reverb | Beta tested in the audio core; not yet on a device | voiceFxStats(), voiceFxAvailable |
| In-ear monitoring | Beta iOS only, builds; not yet tested with headphones on a device | earMonitoringState(), earMonitoringAvailable |
Screen sharing (@ilorb/signal-react-native-screen) PREVIEW
Captures the screen as a WebRTC video track (@livekit/react-native-webrtc ≥ 144) for startScreenShare. Video only: the system sound is not captured; the voice keeps going through the microphone. Built for both platforms; not yet tested on real phones (the iOS extension protocol is tested on the Mac).
import { ScreenCapturePickerView } from '@livekit/react-native-webrtc'
import { IlorbScreen } from '@ilorb/signal-react-native-screen'
const picker = useRef(null)
<ScreenCapturePickerView ref={picker} /> // iOS opens the broadcast picker through it; can be invisible, 1×1
await voice.startScreenShare({ capture: () => IlorbScreen.start({ pickerRef: picker }), stopTrackOnEnd: true })
// by hand: const share = await IlorbScreen.start({ pickerRef: picker }) → { stream, track } … IlorbScreen.stop(share)
// Android: await IlorbScreen.stats(share) → captured frames, size, single-app content
IlorbScreen.start option | Default | Description |
|---|---|---|
pickerRef | — | iOS: ref of a mounted <ScreenCapturePickerView /> (required). |
maxSide | 1280 | Android: longest side of the picture in px, aspect ratio kept (0 = the screen's size). iOS: fixed at 1280 in the extension. |
fps | 15 | Android: capture rate. iOS: up to 15 in the extension. |
entireScreen | false | Android 14+: hide "share one app" in the consent dialog. |
notificationTitle, notificationText, stopLabel | English | Android: the notification shown while sharing and its Stop sharing button. |
android | 'ilorb' | 'livekit' uses react-native-webrtc's own getDisplayMedia() instead of this package's capture. |
| Platform | How it works / what the app needs |
|---|---|
| Android | The package's native module: the system consent (a new one per share), then its own foreground service of type mediaProjection, waited for before MediaProjection starts (react-native-webrtc 144 starts both at once and can end up with a track that never gets a frame). Follows rotation and Android 14's "share one app"; stops from its notification or the system UI end the track and the SDK unpublishes. Keeps sharing with the app in the background. Nothing to add: the service and the FOREGROUND_SERVICE / FOREGROUND_SERVICE_MEDIA_PROJECTION permissions come with the library (Android 13+: ask for notifications to show the Stop button). |
| iOS | A Broadcast Upload Extension (IlorbBroadcast, files in the package's ios-extension/) sends frames to the app over a socket in a shared App Group. Frames arrive only after the person confirms in the system picker; up to 15 fps, longest side up to 1280 px. The app needs UIBackgroundModes: audio to keep sending while another app is on screen. Device only: ReplayKit broadcast does not run in the Simulator. |
| Expo | Config plugin "@ilorb/signal-react-native-screen" (options appGroup, extensionBundleId, extensionName, developmentTeam; defaults group.<bundleId>.screen and <bundleId>.IlorbBroadcast). It creates the extension target on every prebuild (--clean included): files, bundle id, App Group entitlements, deployment target and team of the app, embedded in the app. Also the app's Info.plist keys, App Group and background audio. Register the App Group for both bundle ids in Apple Developer. |
| Bare iOS | Add the extension target in Xcode by hand (README of the package) plus RTCAppGroupIdentifier, RTCScreenSharingExtension and UIBackgroundModes: audio in the app's Info.plist. |
ILORB FX (video effects) BETA
ILORB FX is our video effects engine: color filters, beauty, makeup, face shape, background, 3D stickers, animated overlays and an avatar, applied to the camera on the device, before the encoder. It is a separate package (@ilorb/signal-react-native-fx) with its own license, and works with any react-native-webrtc camera track — with or without ILORB Signal rooms.
License. One line of text, OFX1.<payload>.<signature>: the payload (base64url JSON) lists the app IDs it accepts (iOS bundle ID and/or Android applicationId), the features and the expiry; the signature is ECDSA P-256 / SHA-256. ILORB FX is a subscription per app: in the console, Project → ILORB FX, pick the packages and request it; once ILORB Signal activates it, issue the license there (validity 1–730 days, default 365, never past the end of the subscription). Packages = features: lut color filters · beauty · makeup · reshape face shape · background blur/replace · sticker 3D stickers, animated effects, text balloon and face paint · avatar (all but lut are beta; sticker overlays and avatar are Android only for now; face, makeup and background run on iOS in the simulator, not yet tested on an iPhone). The subscription is per app, in the console menu Effects. The engine verifies it offline with an embedded public key: signature, expiry and the installed app's ID. A license copied into another app does not work; editing the payload breaks the signature. Because the check is offline, revoking in the console does not switch off apps already shipped with that license — it stays valid until it expires.
import { IlorbFx } from '@ilorb/signal-react-native-fx'
const { ok, reason } = IlorbFx.setLicense(LICENSE) // reason: 'format' | 'signature' | 'expired' | 'bundle'
IlorbFx.setFilter(IlorbFx.FILTERS.warm, 1) // filter + intensity 0..1
IlorbFx.setFilter(null) // no filter
// ILORB Signal camera (VoiceClient)
voice.setCameraEffects([IlorbFx.PROCESSOR])
// or any react-native-webrtc video track
track._setVideoEffects(['ilorb-fx'])
Recurring licence (monthly or yearly) BETA
ILORB FX is billed as a recurring licence per app: monthly, or yearly with a discount (prices per package and the yearly discount are set by ILORB Signal and shown in Console → Effects). Subscribe there with a card (Stripe Checkout); change the card, see invoices or cancel in the billing portal; switch monthly → yearly in one click (Stripe credits what is left of the month). The licence you issue never runs past what is paid, so a monthly plan means a fresh licence every period. Let the SDK keep it current with two calls, both with the current licence in x-ilorb-fx-license (an expired one is accepted, up to 400 days old):
// the plan's state as the server sees it
GET /v1/fx/license/status → { app, license: { id, exp, valid, revoked }, subscription: { state, billing, interval, features, validUntil, graceUntil, cancelAtPeriodEnd }, action }
// action: 'keep' (licence fine) | 'refresh' (a newer one can be issued) | 'stop' (no paid plan)
POST /v1/fx/license/refresh → { license, exp, reused, state } // 402 subscription_suspended | subscription_canceled …
States: active · grace (a renewal payment failed: everything keeps working for the grace period while Stripe retries; a licence refreshed now ends with the grace) · suspended (grace over: the server refuses the licence — 402 subscription_suspended on the catalogue and the device report — and the device stops when its licence expires) · canceled (the app's licences are revoked on the server). Paying the open invoice turns it back to active at once. Call status on app start and once a day; on refresh, call refresh and pass the new licence to IlorbFx.setLicense.
Without a valid license the video passes through unchanged (no effect, no error in the call). For debugging, POST /v1/fx/verify with { "license": "OFX1…" } returns { ok, reason, payload } (signature and expiry only; the app ID is checked on the device).
Video enhancement: low light, denoise, sharpen, super resolution BETA ANDROID iOS
Video quality, not an effect: no license needed. lowLight lifts a dark scene automatically (the frame's average brightness decides how much; a bright scene is left alone), denoise removes the grain a phone sensor makes in the dark (it follows the lift), sharpen brings detail back. Each 0..1. It runs on the GPU before the filter and the face effects; on the CPU path only the low-light lift runs. superRes (0..1, off by default) is super resolution: a camera that gives less than upscaleTo pixels on its long side (default 960 — e.g. a cheap phone's 640×480 front camera) is brought up to it as the last step, with an edge-preserving upscale and a contrast-adaptive sharpen of that strength that never makes halos. It is closer to the real full-size picture than the stretch the viewer's screen would do (emulator: SSIM 0.964 → 0.981 against the same scene filmed at full size). Not machine learning; Android GPU path and iOS.
import { IlorbFx, FX_ENHANCE_DEFAULT } from '@ilorb/signal-react-native-fx'
IlorbFx.setEnhance(FX_ENHANCE_DEFAULT) // { lowLight: 0.6, denoise: 0.5, sharpen: 0.25 }
IlorbFx.setEnhance({ ...FX_ENHANCE_DEFAULT, superRes: 0.5 }) // + a small camera brought up to 960
IlorbFx.setEnhance(null) // off
Face, makeup, image & background BETA
setFace replaces the whole set: send everything you want on; {} turns it all off. Strengths are 0..1 (shape values marked −1..1 go both ways). Each group only works with its package in the license; the result tells which ones were applied.
const applied = IlorbFx.setFace({
beauty: { smooth: 0.6, whiten: 0.3, acne: 0.5, darkCircles: 0.4, sharpen: 0.2 },
makeup: { lipstick: { color: '#c2405a', intensity: 0.5 }, blush: { intensity: 0.3 }, lashes: { intensity: 0.6, style: 'curl' } },
reshape: { slimFace: 0.3, bigEyes: 0.2, chin: -0.1 },
image: { temperature: 0.1, brightness: 0.05, saturation: 0.1, contrast: 0.05 },
background: { mode: 'blur', source: 'person', amount: 0.8 }, // 'none' | 'blur' | 'image' | 'mosaic'; source 'green' = green screen
}) // { beauty, makeup, reshape }: false = not in the license
await IlorbFx.setBackgroundImage(jpegBase64) // for background.mode 'image'
IlorbFx.setFace({ ...FX_LOOKS[0].face, beauty: { smooth: 0.5 } }) // ready-made makeup looks
const preview = await IlorbFx.processImage(photoBase64) // "try on a photo" → JPEG base64
FX_LOOKS (full makeup looks), FX_BEAUTY_PRESETS, FX_FACE_SHAPES and FX_SKIN_TONES are exported for building your menu. Call IlorbFx.release() when leaving the live; it also happens by itself 15 s after the camera stops.
Stickers & overlays BETA ANDROID
All in the sticker package. Each call with null removes it.
// 3D model on the head (.glb in base64). anchor = face landmark 0–467, sizes in cm
await IlorbFx.setSticker({
anchor: 1, width: 6, offset: [0, 0, 1], rotation: [0, 0, 0], occlude: true,
react: { shape: 'jawOpen', offset: [0, -1, 0], scale: 0.2 }, // follows an expression
animate: { bob: 0.3, speed: 1 }, // spin, bob, pulse, sway
}, glbBase64)
await IlorbFx.setStickerConfig({ anchor: 1, width: 7, offset: [0, 0.5, 1], rotation: [0, 0, 0] }) // adjust live
// animated 2D effects: up to 8 items and 60 particles
await IlorbFx.setParticles({ items: ['❤️', '💖'], motion: 'orbit', area: 'head', count: 10 }) // orbit | rise | twinkle | fall
// text balloon on the head or fixed on the screen
await IlorbFx.setTextBalloon({ text: 'Hi!', pin: 'head', background: '#ffffff', textColor: '#111111', tail: true })
// face paint: PNG drawn on the front face template (512×584); follows mouth and head
await IlorbFx.setFacePaint(pngBase64, 0.9)
Avatar BETA ANDROID
A mascot that mimics the face in real time (package avatar). 'studio' covers the whole frame — an avatar live where the person does not appear; 'overlay' puts it in place of the head. Feed the voice level and the mouth moves even with no face on camera.
await IlorbFx.setAvatar({ mode: 'studio', style: 'cute', hairStyle: 'bun', accessory: 'glasses', ears: 'cat', background: '#20263a' })
voice.on('localSoundLevel', (level) => IlorbFx.setAvatarVoiceLevel(level / 100)) // ~10×/s, 0–1
await IlorbFx.setAvatar(null)
Remote effects & catalogue BETA ANDROID
Effects are data, not code: an effect package is a JSON manifest in the same parameter model as the setters above (FxFace, a 17³ color LUT, a 3D sticker .glb + placement, a face-paint PNG, particles, hair color) plus its files. The app lists your project's catalogue, downloads an effect on demand, checks it and applies it — a new effect ships without an app build. Every manifest is signed by ILORB Signal (ECDSA P-256 over OFXP1.<manifest>, verified with the key already embedded in the engine) and lists the SHA-256 of each file; nothing is cached or applied unless the signature and every hash check out. Effects are cached in the app's files folder, re-checked before use (a damaged file is downloaded again) and still load offline. The license you set with setLicense authenticates the catalogue calls and says which project's catalogue to list.
IlorbFx.setLicense(LICENSE)
const cat = await IlorbFx.catalog() // { categories, items: [{ id, name, category, kind, version, bytes, thumbUrl, cache: 'none' | 'current' | 'stale' }] }
const fx = await IlorbFx.loadEffect('look-glam-night') // download → verify → cache (FxEffectError: signature | hash | network | not_found …)
await IlorbFx.applyEffect(fx) // setFace / setFilter / setSticker / setFacePaint / setParticles; parts it lacks stay as they are
IlorbFx.effectCache() // { packages: [{ id, versions }], assets, bytes }
IlorbFx.clearEffectCache('look-glam-night') // or () for everything
Console → Effect catalogue: the house catalogue (looks, lipsticks, color filters, skin tones, hair colors, 3D stickers, face paints, particles, packs — over 200 effects) with thumbnails; switch any of them off for a project, or create your own in the editor (sliders and color pickers for the same parameters, .cube LUT upload, face-paint PNG and sticker .glb upload, live preview on a test photo — an approximation in the browser; the phone is the reference). Saving publishes a new signed version; apps see it on their next catalog() (the item says 'stale' where an older version is cached). REST, with the license in x-ilorb-fx-license: GET /v1/fx/catalog · GET /v1/fx/packages/{id}[/{version}] → { manifest, signature } · GET /v1/fx/blobs/{sha256}. iOS: catalog() works; loadEffect answers unavailable for now.
Makeup map BETA
59 makeup models, each approved by eye on our effects demo and then matched by the SDK on the same photo (mean colour difference ΔE under 2 over the face): blush (11), lipstick (5), eyeshadow (6), eyeliner (5), lashes (5), contour (2), skin (4), lenses (4), complete looks (1), freckles (1), highlighter (5), brows (3) and makeup-map stickers (7). On Android the map renders live: the masks are built once and warped through the face mesh on the GPU every frame — 0.6–1.2 ms per effect per frame on the emulator (720×960), a complete look 1.2 ms. iOS has the same placement, curves and lash strip (Simulator).
ILORB FX is a separate subscription per app; ask us which looks are in your license.
Web effects (@ilorb/signal-web-fx) BETA BROWSER
Voice effects, a music player and camera effects for the browser SDK, with the same names as the React Native packages, so code moves between them by changing the import. The voice chain is our native C chain ported to an AudioWorklet; the camera effects run on WebGL2 + MediaPipe.
| Export | Same as | What it does |
|---|---|---|
IlorbAudio | @ilorb/signal-react-native-audio | Voice changer, custom voice, reverb, karaoke echo, ear monitoring, setVoiceMorph |
IlorbMediaPlayer | @ilorb/signal-react-native-audio | A file or URL played into the room (mixed into the mic) |
IlorbMusicTrack | @ilorb/signal-react-native-audio | The music on its own track (voice.startMusicTrack, protocol 11) |
IlorbFx, FILTERS | @ilorb/signal-react-native-fx | Colour filters, beauty, makeup, image, mirror, enhance |
IlorbKaraoke, KaraokeCatalogue | web (core shared with React Native) | Karaoke |
import { VoiceClient } from '@ilorb/signal'
import { ilorbWebRTC, ilorbAudioHooks, IlorbAudio, IlorbMediaPlayer, IlorbMusicTrack, IlorbFx, FILTERS } from '@ilorb/signal-web-fx'
const voice = new VoiceClient({ serverUrl, webrtc: ilorbWebRTC(), hooks: ilorbAudioHooks() })
await IlorbAudio.setVoiceChanger('robot')
await IlorbMediaPlayer.load('/song.mp3'); await IlorbMediaPlayer.start()
await voice.startMusicTrack(IlorbMusicTrack) // optional: the music on its own track
await IlorbFx.setFilter(FILTERS.warm)
IlorbFx.setFace({ makeup: { lipstick: { color: '#c96f7e', intensity: 0.7 } } })
Demo page and a two-browser test: examples/web. Background blur/replace is native-only for now.
Platforms & native SDKs
The TypeScript / React Native SDK is the reference and the only one used in production. The others expose the same functions with the same names; where each one stands (2026-11-02):
| Platform | Package | Status |
|---|---|---|
| Browser | @ilorb/signal (+ web-fx) | Production the console test room runs on it |
| React Native Android | @ilorb/signal + native packages | Production in production apps |
| React Native iOS | same | Beta voice, video and noise reduction on TestFlight; effects equal to Android (Simulator) |
| Android (Kotlin) | packages/android-kotlin | Beta voice, video, screen, attributes, range audio, speech detector and spatial pan, effects, player, native calls, chat; tested on the emulator against the development server |
| iOS (Swift) | packages/ios-swift (SPM, XCFramework, CocoaPods) | Beta voice, video, attributes, audio processing, effects, CallKit and chat; tested on the Simulator and the Mac, not yet on an iPhone. Also runs on macOS (screen source, SwiftUI example). Guide: docs/ios/IOS-INTEGRATION.md |
| Flutter | packages/flutter (over Kotlin and Swift) | In progress example app 35/35 on the Android emulator against the development server; iOS not run yet |
| Desktop | Electron, .NET, Unity | Beta / in progress |
| Server | TypeScript, Python, Go, Java, PHP | Beta 0.2.0, available on request |
Desktop: Electron, .NET and Unity
Electron (@ilorb/signal-electron, macOS and Windows) BETA — the web client + web-fx plus what only a desktop app has: OS media permissions, a screen picker (desktopCapturer) with the computer's sound where the OS allows it, device auto switching, call notifications and the tray. Tested on macOS against the development server (29/29, also packaged); the Windows build is packaged but has not been run on Windows yet.
// renderer
import { createElectronVoiceClient } from '@ilorb/signal-electron'
const ilorb = createElectronVoiceClient({ serverUrl: 'https://api-signal.ilorb.app' })
await ilorb.ensurePermissions() // macOS asks for mic + camera
await ilorb.voice.joinRoom(room, token, { name })
const sources = await ilorb.screen.sources() // your picker
await ilorb.screen.start({ sourceId: sources[0].id, systemAudio: true })
// main process: setupIlorbMain from '@ilorb/signal-electron/main'; preload: '@ilorb/signal-electron/preload'
.NET (packages/dotnet, netstandard2.1 + net8.0) PREVIEW — room signaling, room messages, attributes, chat and the server-side token, no media; tested on the Mac against the development server. Unity (com.ilorb.signal.sdk, the same C# core) — the core is tested; the voice/video layer over com.unity.webrtc is written but has not been compiled in the Unity editor yet. A native Windows media SDK (without Electron) is a plan only.
var voice = new IlorbRoomClient("https://api-signal.ilorb.app"); // Unity: events arrive on the main thread
voice.RoomMessage += (from, text, topic) => Debug.Log($"{from}: {text}");
await voice.JoinRoom(room, tokenFromYourServer, playerName);
await voice.SendRoomMessage("gg");
In-app chat BETA
Messages, groups and call invitations inside your app, with the same user token as the rooms. @ilorb/signal-chat runs in React Native and the browser.
chat-signal.ilorb.app since 2026-10-23 and ChatClient is tested end to end, but no app uses it in production yet and the API may still change.Subscription by MAU. Chat is billed by monthly active users — see subscription by MAU. Pricing coming soon; nothing is charged today.
import { ChatClient } from '@ilorb/signal-chat'
const chat = new ChatClient({ serverUrl: 'wss://chat-signal.ilorb.app' })
chat.on('message', (m) => append(m)) // new message (also mine, from another device)
chat.on('conversationChanged', (c) => updateRow(c)) // unread count, last message…
chat.on('tokenWillExpire', async () => chat.renewToken(await tokenFromYourServer()))
await chat.login('ana', token) // createUserToken without roomId
await chat.updateUserInfo({ name: 'Ana', avatarUrl: 'https://…' })
await chat.sendText('peer', 'bruno', 'hi!')
const { conversations } = await chat.queryConversations()
const { messages, hasMore } = await chat.queryHistory('peer', 'bruno', { limit: 30 })
await chat.markAsRead('peer', 'bruno')
A conversation is (conversationType, conversationId): peer with the other person's userId, or group with the groupId.
| Area | API |
|---|---|
| Login & profile | login, logout, renewToken, updateUserInfo, queryUsersInfo |
| Presence | subscribePresence, unsubscribePresence, queryPresence, event presenceChanged (offline only after 10 s without a device) |
| Messages | sendMessage, sendText, queryHistory; types text, image, audio, video, file, custom |
| Media upload | uploadMedia(blob, { type, mime, name }) → { url }, or createUploadUrl + PUT. Image 10 MB, audio 20, video 95, file 50. |
| Reply, revoke, edit, pin | sendMessage(…, { replyToSeq }), revokeMessage, editMessage, pinMessage, queryPinnedMessages |
| Reactions & receipts | addReaction, removeReaction, markAsRead, queryReadReceipts, event readReceipt |
| Conversation list | queryConversations, searchConversations, pinConversation, muteConversation, deleteConversation, queryTotalUnread |
| Search | searchMessages(keyword, { conversationType?, conversationId? }), searchGroupMembers(groupId, keyword) |
| Groups | createGroup, joinGroup, inviteToGroup, leaveGroup, kickFromGroup, dismissGroup, queryGroupInfo, queryGroupMembers, updateGroupInfo, setGroupAttributes, setGroupMemberRole, transferGroupOwner, setGroupMemberNickname, muteGroupMember, muteGroup |
| Moderation (app) | reportMessage |
Rules: revoke and edit your own messages within 24 h (group owner/admin can revoke any); up to 20 pinned messages per group; 10 messages/s per person. History is kept on the server, and optionally on the device. Public channels for very large audiences and message translation: below (preview).
Offline history & send queue
Give the client a storage and it keeps the conversation list and the latest messages on the device: the app opens with them even without network, and messages sent offline wait in a queue that survives closing the app.
import { ChatClient, indexedDBStorage, asyncStorageAdapter } from '@ilorb/signal-chat'
import AsyncStorage from '@react-native-async-storage/async-storage' // React Native (your app installs it)
const chat = new ChatClient({ storage: asyncStorageAdapter(AsyncStorage), cache: { maxMessagesPerConversation: 50 } })
await chat.login('ana', token, { waitForConnection: false }) // returns right away: opens without network
chat.getCachedConversations() // the list, immediately
await chat.getCachedHistory('peer', 'bruno') // the latest N + queued ones (pending: true)
chat.on('synced', reload) // connected and the cache is up to date
chat.on('pendingMessageSent', (m) => replace(m.localId, m)) // a queued message went out
await chat.logout() // clears the cache ({ keepCache: true } keeps it)
| Item | Details |
|---|---|
storage | MemoryStorage, indexedDBStorage(), webLocalStorage(), asyncStorageAdapter(AsyncStorage), or any object with get / set / delete / keys(prefix) (all async, string values). Without it nothing is kept on the device. |
| Send queue | sendMessage without network goes to the queue and its promise resolves when the message is sent. On reconnect the queue goes out by itself, in order (pendingMessageSent); if the server refuses one it leaves the queue (pendingMessageFailed). The localId makes resending never duplicate. pendingMessages(type?, id?) lists what is waiting. |
| Sync on connect | Reloads the list and, for each stored conversation, fetches only what is missing by seq (queryHistory({ afterSeq })). If more than the limit is missing, it switches to the newest window — no gap in the middle. |
cache limits | maxMessagesPerConversation (50), maxConversations with stored messages (100), maxBytes (5 MB). Above that, the least recent conversations go. |
| Other | clearCache(). Edits or reactions made while the app was closed to an old cached message update when the conversation is opened. |
Public channels & translation PREVIEW
chat-signal.ilorb.app.A channel is public: anyone in your app finds it, reads it and joins it without an invite. A channel message is stored once and broadcast through socket shards — no per-member inbox and no unread counter per member — so one channel can hold a very large audience. The SDK opens a live socket per joined channel, reconnects it and catches up by seq after a drop.
const { channels } = await chat.searchChannels({ keyword: 'music' })
const { info, messages } = await chat.joinChannel(channels[0].channelId) // live from now on
chat.on('channelMessage', (m) => show(m)) // m.senderName comes with it
await chat.sendChannelText(info.channelId, 'hi all')
const t = await chat.translateMessage(messages[0], 'pt') // t.text, cached for everyone
| Area | Methods / events |
|---|---|
| Channels | createChannel, joinChannel, leaveChannel, queryChannelInfo, queryChannelHistory({ beforeSeq | afterSeq }), sendChannelMessage, sendChannelText, sendChannelMediaMessage, revokeChannelMessage, searchChannels, queryJoinedChannels; events channelMessage, channelMessageUpdated, channelChanged, channelConnectionStateChanged |
| Owner / admins | updateChannelInfo (name, notice, slowModeSeconds, postingMode: 'everyone' | 'admins'), muteChannelMember, setChannelMemberRole, deleteChannel |
| Translation | translateMessage(message, targetLanguage, { sourceLanguage? }) for 1:1, group and channel text messages; up to 2,000 characters, 30 per person per minute |
| App server | POST /v1/server/channels { ownerId, name, shards? }, DELETE /v1/server/channels/{id}, POST /v1/server/channels/{id}/messages (system allowed) |
Limits: 50 messages/s per channel (error rate_limited), 10/s per person, the newest 10,000 messages kept, up to 200 joined channels per person. Errors: not_member, channel_deleted, translation_unavailable.
Push (app closed)
Register the device's push token and the chat decides when to notify; your server delivers through its own FCM / APNs (webhook mode).
await chat.setPushToken('fcm', fcmToken, deviceId, 'en') // Android
await chat.setPushToken('apns', apnsToken, deviceId, 'en') // iPhone: messages
await chat.setPushToken('apns-voip', voipToken, deviceId, 'en') // iPhone: calls (PushKit → CallKit)
await chat.setPushSettings({ showPreview: false, mutedUntil: 0 })
await chat.logout() // removes this device's token ({ keepPushToken: true } keeps it)
Rules: only when the user has no device connected; muted conversations don't notify; 1:1 right away; groups are batched per conversation (3 s); a call invitation is high priority (VoIP on iPhone) and a cancelled or unanswered one becomes "missed call". Up to 10 devices per user; texts in en / pt / es by the device locale. Also removePushToken(deviceId). Your server receives a signed chat.push callback:
{ "event": "chat.push", "userId": "davi", "kind": "message | call | call_missed | test", "priority": "normal | high",
"devices": [{ "deviceId": "ios-1", "provider": "apns", "token": "…", "locale": "en" }],
"title": "Ana · Group", "body": "three (3 new messages)", "badge": 5, "collapseKey": "group:pushg",
"data": { "conversationType": "group", "conversationId": "pushg", "seq": 9, "messageId": "m_…", "count": 3 },
"raw": { "senderName": "Ana", "conversationName": "Group", "preview": "…", "count": 3 } }
devices is already filtered (VoIP only for calls); use raw to build your own text. Test it with POST /v1/server/push/test { userId }.
Delivering it: new PushSender({ fcm, apns }).send(body) from @ilorb/signal-server/push sends it through your own Firebase project (FCM HTTP v1; calls are data-only, high priority, with the invitation's TTL) and Apple key (calls as VoIP pushes to <bundle>.voip, everything else as alerts). A result with unregistered: true means the token is dead (app uninstalled): send those tokens to POST /v1/server/push/unregister { userId, tokens } and the chat stops using them. The sample server in examples/web/callbacks.mjs does both.
Call invitations
Ring someone, then everyone joins an ILORB Signal room. The room token still comes from your server.
// caller
const call = await chat.callInvite(['bruno', 'carla'], { roomId: 'call-123', timeoutSeconds: 60, extendedData: '{"type":"video"}' })
chat.on('callUserStateChanged', ({ call, users }) => render(users)) // inviting | accepted | rejected | cancelled | timeout | quit
chat.on('callEnded', () => voice.leaveRoom())
// callee
chat.on('callInvitationReceived', async ({ call }) => {
await chat.callAccept(call.callId)
await voice.joinRoom(call.roomId, await roomTokenFromYourServer(call.roomId))
})
Also callReject, callCancel, callingInvite (add people mid-call), callQuit (leave without ending), callEnd, queryCallInfo, queryCallList. Up to 10 invitees per call to callInvite, 50 per call; timeout 5–600 s. A user who was offline gets a still-valid invitation when they connect; a phone with the app closed is rung by a high-priority push (VoIP on iPhone).
Chat server API
From your server, with the same Basic auth as the rooms:
| Endpoint | What it does |
|---|---|
GET / PUT /v1/server/moderation | { bannedWords, wordAction: 'mask' | 'reject', hook: 'off' | 'notify' | 'review' } |
GET / PUT /v1/server/settings | { retentionDays, maxGroupMembers }. Retention: messages older than this are deleted, also from search and from the conversation list preview; default 90 days, 0 = keep forever, up to 3650. Group size: default 500, up to 2000; above it, creating, inviting or joining fails with group_full. Channels with thousands of members are a separate product (coming later). |
POST /v1/server/messages | Send as a user or as system. |
POST /v1/server/messages/revoke | Remove a message by moderation. |
POST / DELETE /v1/server/users/{id}/ban | Ban (durationSeconds, 0 = forever) — disconnects their devices at once — or lift it. |
POST /v1/server/users | Set a profile from the server. |
GET /v1/server/reports | User reports. |
GET /v1/server/callbacks | Recent chat.* callback deliveries. |
POST /v1/server/push/test | { userId } → sends a chat.push of kind test for that user's devices. |
POST /v1/server/push/unregister | { userId, tokens: [...] } → removes push tokens FCM/APNs reported as dead (matched by token: a device that already registered a new one keeps it). Answers { removed, devices }. |
GET /v1/server/mau?months=6 | Chat MAU per month (UTC) for the project. |
Callbacks go to the project's callback URL, signed like the room ones: chat.message.review (hook review, before saving; answer { action: 'allow' | 'reject' | 'replace' } within 2 s), chat.message.sent (hook notify), chat.message.reported, chat.call.created, chat.call.ended, chat.push. The server SDKs wrap all of these.
Subscription by MAU
Chat is a subscription per project billed by monthly active users: each user who logs in to the chat counts once per month (UTC) in that project, however many devices and logins. The structure follows the usual MAU model:
| Plan | MAU included | Price |
|---|---|---|
| Free | 100 per project | free |
| Paid plans | a monthly MAU quota, plus a price per MAU above it | coming soon |
See it in Console → Chat (this month and previous months per project, the plans, and a subscription request) and in each project's Chat tab. Above the quota the console only warns — nothing is blocked. Payment is not in the console yet: a request stays "awaiting payment" until ILORB Signal activates it. From your server: GET /v1/server/mau.
UIKit for React Native PREVIEW
Five ready-made screens on top of ChatClient and VoiceClient: @ilorb/signal-react-native-uikit. Only react and react-native — no icon or navigation library — and the video is drawn by your app. Also runs in the browser through react-native-web.
| Screen | Uses | What it does |
|---|---|---|
ChatList | ChatClient | Conversation list: pinned on top, unread, muted, live updates (conversationChanged); loading / empty / error states. |
ChatThread | ChatClient | A conversation: paged history on scroll, optimistic send (tap "!" to resend), reply and react (long press), "Read" in 1:1, sender name in groups, revoked / edited, photos. |
CallScreen | ChatClient.callInvite + VoiceClient | Calling / ringing / in call: joins the room when someone accepts, speaking ring, mute, camera, timer; hanging up ends a 1:1 and leaves a group call. |
AudioRoom | VoiceClient | Seats (host first), speaking ring, mute, listeners, raise hand → the host invites, leave the seat. |
LiveVideo | VoiceClient | Camera grid; tapping one shows it in HD and the others in low (setVideoQuality), with an HD / Low badge per camera. |
import { UIKitProvider, ChatList, ChatThread, CallScreen, AudioRoom, LiveVideo, darkTheme } from '@ilorb/signal-react-native-uikit'
import { RTCView } from '@livekit/react-native-webrtc'
const renderVideo = ({ stream, mirror, style }) => <RTCView streamURL={stream.toURL()} mirror={mirror} objectFit="cover" style={style} />
<UIKitProvider theme={darkTheme}>
<ChatList chat={chat} onOpen={(c) => open(c)} />
<ChatThread chat={chat} conversationType="peer" conversationId="ana" title="Ana" onBack={back} />
<CallScreen chat={chat} voice={voice} invitees={['ana']} getRoomToken={(roomId) => myServer.token(roomId)} onClose={close} />
<AudioRoom voice={voice} roomId="room-1" token={token} hostId="host" onLeave={leave} />
<LiveVideo voice={voice} roomId="live-1" token={token} renderVideo={renderVideo} onLeave={leave} />
</UIKitProvider>
- Theme:
lightTheme/darkTheme, or your own — even just the brand color:{ ...lightTheme, colors: { ...lightTheme.colors, primary: '#E85D04' } }. - Texts: English by default;
ptBRis included; override any key withstrings={{ send: 'Go' }}. - Clients: the screens only need the shape (
ChatLike,VoiceLike): the realChatClientandVoiceClientfit directly, and a fake one works for tests and demos. - Answering a call:
chat.on('callInvitationReceived', ({ call }) => show(<CallScreen call={call} … />)). With the app closed, the call push brings it up (CallKit / full-screen on Android). - Raise hand uses room messages with the topic
uikit:{ t: 'hand' | 'invite' | 'down' }. - Offline history: with
ChatClient({ storage }),ChatListandChatThreadshow the cache immediately (even offline); messages sent offline show 🕓 until they go out, and "!" if the server refused them.
Not yet: sending photo / audio / file from ChatThread (uploadMedia exists; the picker depends on your app's library), PK and cloud mix in LiveVideo.
Server REST reference
All endpoints use Basic auth with the project credentials and return JSON with an ok field. Errors return { ok: false, error } with HTTP 4xx.
Account balance
{
"ok": true,
"tenantId": "ten_3f9a…",
"status": "active", // active | suspended
"creditSeconds": 5400000, // participant-seconds
"creditMinutes": 90000,
"usage": [{ "app_id": "app_…", "day": "2026-10-20", "seconds": 41220 }] // last days, per project
}
Live rooms of the project
{ "ok": true, "rooms": [
{ "room_id": "room-42", "participants": 18, "publishers": 4,
"started_at": "2026-10-20T13:00:00Z", "updated_at": "2026-10-20 13:41:10" }
] }
Who is in a room
{ "ok": true, "roomId": "room-42", "startedAt": "2026-10-20T13:00:00Z",
"people": [{ "userId": "42", "name": "Ana", "publishing": true, "micOpen": false }] }
Remove a person
curl -u "$ID:$SECRET" -X POST https://api-signal.ilorb.app/v1/rooms/room-42/kick \
-H 'content-type: application/json' -d '{"userId":"42"}'
The person stops hearing and speaking immediately (their tracks are closed on the media server) and receives the kicked error. Returns 404 not_in_room if they are not there. Remember to stop issuing tokens for that room to them, or they can join again.
Close a room
{ "ok": true, "removed": 18 }
Everyone receives room_closed and is disconnected. The room's attributes are deleted. The room id can be used again afterwards.
Room & member attributes
{ "room": { "topic": "Final", "seat1": null }, // null deletes
"members": { "bruno": { "role": "vip" } }, // people in the room now
"writers": "admin" } // 'everyone' (default) | 'admin'
// → { "ok": true, "attributes": { "topic": "Final" }, "writers": "admin", "members": { "bruno": { "role": "vip" } } }
All fields are optional; everything is checked before anything changes. Works on an empty room too (set the topic before anyone joins). Everyone receives the changes with by: '__server'. Errors: 400 attrs_invalid, 413 attrs_too_large, 404 not_in_room (a member who is not there). GET /v1/rooms/{roomId} returns attributes, attributeWriters and members (each person's attributes).
Cloud recording BETA
Records the room as one video: the grid of cameras with every voice mixed (720×640 by default), in MP4 chunks (H.264 + Opus) of up to 60 s stored by ILORB Signal. Starting a recording turns on the room's composition by itself; it stops by itself when the room empties.
{ "action": "start", "segmentSeconds": 60 } // or { "action": "stop" }; segmentSeconds 5–300
// → { "ok": true, "recording": { "id": "mviv1a2b", "startedAt": "2026-10-21T22:00:00.000Z", "seconds": 0 } }
Starting again while it records returns the same recording. Errors: 409 (the room is empty), 503 recording_unavailable, 502 mixer_failed. GET /v1/rooms/{roomId} shows recording while it runs.
{ "ok": true, "recordings": [{
"roomId": "room-1", "id": "mviv1a2b", "startedAt": "2026-10-21T22:00:00.000Z",
"seconds": 127.5, "bytes": 23110421,
"files": [{ "name": "seg-0000-00000.mp4", "size": 10893212, "duration": 60, "uploaded": "…" }, …]
}] }
Newest first. Play the files in order (each is a complete MP4). duration is read from the file itself; seconds is their sum. Each recording has kind: "room" (the mix) or "person" (with the userId, see below).
Per-person recording
Records each person separately: their own voice and their own camera (or screen) in their own MP4 chunks (H.264 640×480 + Opus; audio only for someone with no camera). It runs alongside the room recording and the mix, and does not need either of them.
{ "action": "start", "scope": "person" } // everyone publishing, and whoever starts publishing later
{ "action": "start", "scope": "person", "userIds": ["ana"] } // only these people
{ "action": "stop", "scope": "person", "userIds": ["ana"] } // stop these; without userIds: stop all
// → { "ok": true, "started": ["ana", "bia"], "everyone": true,
"personRecordings": [{ "userId": "ana", "id": "mvyiljsspqed", "startedAt": "…", "seconds": 0, "inRoom": true }] }
Each person gets their own recording id; listing, download, delete and retention work as for the room recording. Someone stopped one by one is not picked up again by "everyone" until you start them by userIds. Someone who leaves keeps their recording open for 60 s (a reconnection continues it), then it stops; an empty room stops them all. Errors: 404 not_in_room (none of the userIds is in the room), 409 (empty room), 503 recording_unavailable, 502 mixer_failed. GET /v1/rooms/{roomId} shows personRecordings and personRecordingEveryone. Callbacks: recording.person.started and recording.person.stopped. Server SDKs: RoomsClient.record_person() (Python) and RecordPerson() (Go).
Mix layout
The room mix (and so the room recording and RTMP) uses a grid by default. POST /v1/rooms/{roomId}/mix { "action": "start", "layout": "focus", "focusUserId": "ana" } puts one person big on top and the others in a strip below; { "action": "layout", "layout": "grid" | "focus", "focusUserId"? } changes it while it runs. GET /v1/rooms/{roomId} → mix.layout, mix.focusUserId.
The MP4 file. Supports Range, so a <video> can seek.
{ "ok": true, "deleted": 3 } // files removed; 404 if there is no such recording
Retention. Each project keeps recordings for 30 days by default; change it in Console → Recordings (0 = keep forever). Once a day (03:00 UTC) recordings that started earlier than that are deleted. Console: Develop → Recordings lists them by project, room and date, plays the chunks one after the other, downloads and deletes; Live has a Record / Stop button for an open room. Callbacks: recording.started and recording.stopped (below). Recording is not billed during the beta.
Content moderation BETA
Live video and audio are checked while people publish. The SDK samples by itself: a JPEG frame of the camera every video.intervalSeconds and an audio clip of audio.clipSeconds every audio.intervalSeconds, only while that track is published and open. Pick one of three ways to judge them, per project (Console → project → Moderation, or the API below):
| Mode | Frames | Audio |
|---|---|---|
provider | Your recognition provider key: sightengine (api_user:api_secret, nudity score) | Your speech-to-text key: deepgram; the transcript is matched against your banned terms |
ai | Your AI key: openai (or any OpenAI-compatible baseUrl) or anthropic; a vision model scores nudity | Transcribed (OpenAI-compatible, or your speech key) and judged for abuse, plus your banned terms |
webhook | Every sample goes to your URL as moderation.check (signed like the callbacks, the media inline in base64 when under 300 KB and as a 24 h link); your admin decides | |
Keys are stored encrypted and never returned (only a hint). Provider test (deterministic: the scores come from test.nudity / test.transcript) exists for automated tests. Only violations and webhook checks keep their media (30 days).
{ "mode": "provider", "threshold": 0.8, "banMinutes": 1440,
"video": { "enabled": true, "intervalSeconds": 15, "action": "takedown", "takedownMinutes": 10 }, // action: takedown | report
"audio": { "enabled": true, "intervalSeconds": 30, "clipSeconds": 8, "action": "review", "bannedTerms": ["…"] }, // action: review | auto_ban
"provider": { "name": "sightengine", "key": "api_user:api_secret" }, "speech": { "name": "deepgram", "key": "…" },
"ai": { "provider": "openai", "key": "…", "model": "gpt-4o-mini", "baseUrl": "https://api.openai.com/v1" },
"webhook": { "url": "https://your-server.com/ilorb/moderation" } } // partial updates; "key": null removes a key
Basic appId:appSecret. GET /v1/moderation/settings returns them (keys as hints) plus effective: what the room asks the SDK to sample. Open rooms pick changes up within 30 s.
Video. A frame at or above threshold is a violation. With takedown the room removes the camera at once (viewers lose it), the publisher's SDK fires streamTakenDown and startCamera is refused with stream_taken_down until takedownMinutes pass (0 = until you restore it). With report it only becomes a flag. Audio. A banned term or an abusive transcript becomes a pending flag: you approve or reject the ban (or set auto_ban).
{ "ok": true, "flags": [{ "id": "mf_…", "roomId": "live-1", "userId": "42", "kind": "audio", "source": "provider:deepgram",
"score": 1, "reason": "banned terms: …", "transcript": "…", "matched": ["…"], "hasMedia": true,
"status": "pending", "action": "none", "createdAt": "…" }] } // status: checking | pending | confirmed | dismissed | clean
GET /v1/moderation/flags/{id} · GET /v1/moderation/flags/{id}/media (the JPEG or the clip).
{ "decision": "confirm" } // audio: approves the BAN · video: takes the camera down if it was only reported; "ban": true bans too
{ "decision": "dismiss" } // rejects: restores a camera that was taken down, lifts an automatic ban
// → { "ok": true, "status": "confirmed", "action": "ban" }
A banned user is removed from the room (the SDK gets moderationAction banned and the terminal banned error) and refused on join in every room of the project until banMinutes pass. GET /v1/moderation/bans · POST /v1/moderation/bans { userId, minutes?, reason?, roomId? } · DELETE /v1/moderation/bans/{userId}.
Webhook mode: answer the moderation.check right away in the HTTP response ({ "violation": true | false, "score"?, "reason"? }, within 8 s), or later here with the same body. A violation then follows the video/audio rules above.
// moderation.check (POST to your URL, x-ilorb-signature like the callbacks)
{ "event": "moderation.check", "checkId": "mf_…", "appId": "…", "roomId": "live-1", "userId": "42", "kind": "video",
"contentType": "image/jpeg", "mediaBase64": "/9j/…", "mediaUrl": "https://api-signal.ilorb.app/v1/moderation/media/mf_…?exp=…&sig=…" }
SDK. Nothing to call: the room sends the config (moderationConfig event) and the SDK samples. On Android the native package @ilorb/signal-react-native-audio provides the capture (a camera frame processor that sees what viewers see, and the clip of what the mic sends after the voice chain); in the browser it uses the camera and mic tracks. An app may capture by itself with voice.moderation.setCapture({ frame, audio }); voice.moderation.stats shows what was sent. Events: moderationAction ({ action: 'stream_taken_down' | 'stream_restored' | 'banned' | 'warning', kind, reason, flagId, until }), streamTakenDown, streamRestored. Callbacks: moderation.flagged, moderation.stream_taken_down, moderation.user_banned, moderation.decided, moderation.samples_missing (a publisher whose SDK sends no samples). Demo: examples/moderation-demo.
Live Shop BETA
Live shopping on top of a live room: the host pins products of your own store during the live and every viewer sees the product card (name, image, price, stock, variants) in real time, then taps to open the product page or add it to the store's own cart. No payments in ILORB Signal: the checkout is your store's site. Stores: Shopify and Nuvemshop / Tiendanube, one per project (Console → project → Live Shop, or the API below).
| Store | What you give | What is read |
|---|---|---|
shopify | domain (my-store.myshopify.com or your own domain). Optional: an Admin API access token (shpat_…, scopes read_products + read_inventory) | Without a token: the public catalogue (/products.json: price, availability, no stock numbers). With it: the Admin API, stock included. Cart link: https://{store}/cart/{variant}:1 |
nuvemshop | storeId + the access token of your Nuvemshop app (scope read_products) + domain (the storefront) | The Nuvemshop API (/products, /store), stock included. No cart link: "add to cart" opens the product page |
Tokens are stored encrypted and never returned (only a hint).
{ "platform": "shopify", "domain": "my-store.myshopify.com", "token": "shpat_…", "storefrontUrl": "https://www.my-store.com" }
{ "platform": "nuvemshop", "domain": "my-store.lojavirtualnuvem.com.br", "storeId": "1234567", "token": "…" } // "token": null removes it
Basic appId:appSecret. GET /v1/shop/store · DELETE /v1/shop/store · POST /v1/shop/sync (imports the catalogue now, up to 2,000 products) · GET /v1/shop/products?q=&limit=&offset= · GET /v1/shop/stats?days=7&room= (pins, opens and add-to-cart taps per product and per room).
{ "productId": "10568701444152", "variantId": "51436343066680" } // variantId optional · also …/shop/unpin, …/shop/list { add?, remove?, list? }
The host's SDK (a token with admin: true) or your server (Basic). The server re-reads the product from the store (fresh price and stock), then writes the room attributes shop:pinned (a snapshot of the product, ≤ 1,024 characters: as many variants as fit) and shop:list (the live's product ids). Only the server writes shop:* keys: a client that tries gets attrs_denied.
// host
const { products } = await voice.shop.catalogue({ query: 'board' })
await voice.shop.pin(products[0].id) // error not_host without an admin token
await voice.shop.unpin()
// everyone
voice.shop.on('pinnedProduct', (p) => showCard(p)) // null = unpinned; late joiners get it on join
voice.shop.on('productsChanged', (ids) => refreshBag(ids)) // voice.shop.liveProducts() = full details
const url = voice.shop.openProduct(p, { variantId, addToCart: true }) // counts the tap; you open the URL
PinnedProduct: id, title, url, image, price, compareAtPrice, currency, available, stock (null when the store does not publish it), variants, variantCount, variantsComplete, links { product, variant, cart }, pinnedAt, pinnedBy. voice.shop.getProduct(id) returns every variant. Demo: examples/live-shop-web.
Live gifts BETA
A viewer buys one gift with money for someone in the live — a one-time purchase through Stripe Checkout — and ILORB Signal delivers it into the room exactly once when the payment is confirmed: everyone in the room gets a room message with topic gift, and your server gets the gift.purchased callback (credit the recipient however your app does). A repeated or delayed payment event never shows a gift twice.
Console → Live gifts: add gifts from the ILORB Signal gift shop (animated gifts with a transparent background) and set each one's price (US$ 0.50 minimum; a gift with no price is not on sale). Orders and deliveries are listed there.
// with the viewer's room token (?token= or Authorization: Bearer)
GET /v1/rooms/{roomId}/gifts → { available, testMode, gifts: [{ giftId, name, priceCents, currency, animationUrl, alphaLayout, thumbUrl }] }
POST /v1/rooms/{roomId}/gifts/checkout { giftId, to, successUrl?, cancelUrl? } → { orderId, url, amountCents, currency }
// open url in the browser (in-app browser / Custom Tab); successUrl/cancelUrl: https, default a page that says "go back to the app"
GET /v1/rooms/{roomId}/gifts/orders/{orderId} → { order: { status: 'pending' | 'delivered' | 'expired' | …, deliveredAt, viewers } }
// everyone in the room
voice.on('roomMessage', (from, text, topic) => {
if (topic !== 'gift') return
const g = JSON.parse(text) // { orderId, giftId, name, animationUrl, alphaLayout, thumbUrl, from, to, amountCents, currency, at }
playGift(g.animationUrl) // alpha MP4, mask on the left (IlorbAlphaVideoView on React Native)
})
The buyer is the token's user; to is any userId (not the buyer). An unpaid Checkout expires after 31 minutes. Payments go to ILORB Signal's Stripe account; paying out to hosts is up to your app (the callback says who received what).
Billing & payments
Three ways ILORB Signal charges, all paid by card through Stripe in the console:
- Participant-minutes — prepaid top-up. Console → Billing: pick an amount, pay, the credit is added as soon as Stripe confirms (and a room refused for lack of credit opens right after). Rooms close with
insufficient_creditat zero. - ILORB FX — recurring licence, monthly or yearly with a discount, per app: see Recurring licence.
- Live gifts — one-time purchase by your viewers, delivered in the room: see Live gifts.
Every price is set by ILORB Signal in the admin panel (the gift prices by you, per project). Stripe sends its events to one signed endpoint; each event is applied once (by its id), events older than what was applied are ignored, and only test-mode events are accepted until live payments are switched on.
Cloud player BETA
Play a video or a song from the internet in the room, for everyone: a watch party, background music, a clip during a live. ILORB Signal fetches the URL, encodes it (H.264 + Opus) and publishes it in the room as the person __player. Every app receives it like anyone else: userJoined, videoTrackChanged and the voice. No change is needed in the app.
{ "action": "play", "url": "https://cdn.example.com/trailer.mp4", "loop": false, "volume": 0.8 }
{ "action": "pause" } { "action": "resume" } { "action": "seek", "ms": 20000 } { "action": "stop" }
// → { "ok": true, "player": { "state": "playing", "positionMs": 0, "durationMs": 152000, "hasVideo": true, "hasAudio": true, "loop": false, … } }
| Item | Rule |
|---|---|
| URL | http / https: MP4, M4A, MP3 and HLS (.m3u8, also live). An audio-only file publishes only the voice track. The server must answer Range requests, as CDNs do. |
| Not accepted | Internal addresses (localhost, private and link-local IPs, .local/.internal), including through redirects; other schemes (file, ftp…). Up to 4 h and 20 Mb/s per media. Errors: 400 player_url (with a message), 409 (the room is empty / nothing playing), 502 player_failed, 503 player_unavailable. |
| Pause / seek | The __player tracks stay the same, so nobody resubscribes. While paused, cameraOn / micOpen are false (show a cover, not a frozen frame). Resume and seek are back in about 1 s. |
| End | Without loop, when the file ends the __player leaves the room by itself (callback player.ended). It also stops when the room empties. Playing a new URL replaces the current one. |
| State | GET /v1/rooms/{roomId} → player (state: playing | paused, positionMs, durationMs, tracks…). A room recording includes what the player plays. |
Callbacks: player.started, player.paused, player.resumed, player.seeked, player.stopped, player.ended. The player is not billed during the beta (people in the room are billed as usual).
Viewers (audience) PREVIEW
For a live with a big audience, give the audience a viewer token: createUserToken({ …, role: 'viewer' }). A viewer watches and listens; it does not publish and nobody sees it in the list of people — the room only keeps a counter. Tokens without role behave exactly as before.
const voice = new VoiceClient({ serverUrl })
voice.on('viewersChanged', (n) => setAudience(n)) // everyone gets it, at most once per second
await voice.joinRoom(roomId, viewerToken)
voice.isViewer() // true
voice.getViewerCount() // how many viewers are in the room
voice.getReceiveMode() // 'mix' while the cloud mix is on, otherwise 'individual'
| Item | Rule |
|---|---|
| What a viewer receives | With the cloud mix on (POST /v1/rooms/{roomId}/mix { "action": "start" }, see mix layout), the room puts every viewer on the mix by itself: one video (the grid) and one voice — the person __mix — whatever the size of the stage. Without the mix, each viewer receives every camera and voice on stage, like everyone else. When the mix starts or stops, viewers move over without reconnecting (receiveModeChanged); the room, not the app, picks a viewer's receive mode. |
| Publishing | startPublishing, startCamera, startScreenShare and startMusicTrack throw viewer_cannot_publish. The room refuses a publish, a position, the viewer's own attributes and taking a seat with the same code — it is not terminal: the viewer keeps watching. To bring someone on stage, give them a normal token and join again. |
| Who sees viewers | Nobody: no userJoined, not in getUsers(), not in people of the room state. Everyone (stage and viewers) gets viewersChanged(count); the room state has viewers (absent = none). Callbacks user.joined and user.left carry role: "viewer". Viewers can still send room messages and be kicked. |
| Cost warning | Without the mix, above 20 viewers the room state shows warnings: ["viewers_without_mix"]: each viewer then receives every camera and voice of the stage. Turn the mix on. |
| Billing | A viewer is a participant (participant-minutes as usual). Traffic estimate: a viewer on the mix counts one voice, not one per open microphone; video counts what is really received. |
| Limits | Up to 1 000 viewers per room, outside the room size of your plan (which counts the people on stage). A viewer is never a PK host. |
Stream with OBS PREVIEW
api-signal.ilorb.dev) today; it reaches api-signal.ilorb.app with the next server release. RTMP needs the ingest gateway, which is not deployed yet: until then obs.rtmp is null — use WHIP (OBS 30 or newer).A streamer goes live in a room from OBS Studio (game streaming, a capture card, a desktop studio). OBS 30+ publishes over WHIP straight into the room (sub-second, ~50 ms glass-to-glass measured); older OBS and other encoders use RTMP through the ILORB ingest gateway. The streamer shows up for everyone as the person __ingest:<userId> with a voice track and a camera (or screen) track, up to 1080p60 — a normal publisher: userJoined, videoTrackChanged, the voice, simulcast layers when the encoder sends them. No change is needed in the viewers' app. Full guide with the OBS settings: docs/STREAM-WITH-OBS.md.
// your server (Basic appId:appSecret) — any user and room; or the app with a room token created with ingest: true (only its own user)
{ "room": "live-42", "userId": "streamer-7", "kind": "camera" } // no room = a per-user key (any room)
// → { "key": { "keyId": "ik_…", "secret": "osk_…", "ingestUserId": "__ingest:streamer-7", … }, "limits": { "maxWidth": 1920, "maxHeight": 1080, "maxFps": 60, "maxKbps": 6000 },
// "obs": { "whip": { "server": "https://api-signal.ilorb.app/v1/ingest/whip/live-42", "bearerToken": "osk_…" }, "rtmp": { "server", "streamKey" } | null, "encoder": { … } } }
| Call | What it does |
|---|---|
GET /v1/ingest/keys?room=&userId= | The keys (never the secret: hint = its last 4 characters). |
DELETE /v1/ingest/keys/{keyId} | Revokes: refused from now on (WHIP 401, RTMP dropped); a stream live with it ends at once. |
GET /v1/ingest/rooms/{roomId} | Who streams into the room now (state connecting | live, protocol whip | rtmp, codec, simulcast layers). Also in GET /v1/rooms/{roomId} → ingest. |
POST /v1/ingest/whip/{roomId} | The WHIP endpoint OBS posts to (Authorization: Bearer <secret>, application/sdp) → 201 + Location; DELETE on the Location ends it. H.264 or VP8 + Opus; anything else 415 unsupported_codec. |
| Limits | By the app plan (basic 720p30 3 Mb/s · standard 1080p60 6 Mb/s · pro 1080p60 10 Mb/s; streams at once per room 1 / 2 / 4), lowered per key with maxHeight, maxFps, maxKbps. WHIP: the answer caps the bitrate; RTMP: the gateway scales and re-encodes to the limits. |
| SDK | Browser/RN: voice.createStreamKey(), revokeStreamKey, listStreamKeys, getIngestStatus, event ingestChanged ({ userId, state: 'live' | 'ended', protocol, reason }). Node: IngestClient from @ilorb/signal-server/ingest. Token: createUserToken({ …, ingest: true }). |
| Ends when | OBS stops (DELETE), the key is revoked, the server kicks __ingest:<userId>, the room is closed or out of credit, or OBS vanishes (no media for ~30–60 s). |
Callbacks: ingest.live (userId, streamer, keyId, protocol, tracks, videoCodec, simulcast) and ingest.ended (reason: stopped | revoked | kicked | disconnected | no_media | replaced | room_closed | insufficient_credit, durationMs). A live ingest is billed as one participant publishing; it goes into the cloud mix and recordings like a person.
OBS settings. Settings → Stream: Service WHIP, Server = obs.whip.server, Bearer Token = the osk_… secret. Output: x264 or a hardware H.264 encoder (NVENC, QuickSync, AMF, Apple VT), CBR 4,500 kbps for 720p60 or 6,000 for 1080p60, keyframe interval 1 s, no B-frames (bframes=0; NVENC: Max B-frames 0), tune zerolatency. HEVC/AV1 are refused (415).
| Measured on the development server (OBS simulated with the same WebRTC stack) | At the viewer | Glass-to-glass (median / p95) |
|---|---|---|
| WHIP 720p60 H.264 4.5 Mb/s | 1280×720, 57.6–59.9 fps | 42–60 ms / 57–68 ms |
| WHIP 1080p60 H.264 6 Mb/s | 1920×1080, 58.1–59.9 fps | 55–59 ms / 62–72 ms |
| RTMP 720p60 → gateway (transcode) | 1280×720, 54–59 fps | 188–255 ms / 285–305 ms |
Viewers see "live" 1.5–3.3 s after OBS connects; an OBS that crashes without stopping is removed in 41–61 s. Not proven yet: the real OBS Studio app and a gateway on a real server. Console: Develop → Stream keys creates and revokes keys and shows the OBS fields.
ILORB Live Studio BETA
Our desktop streaming app for creators (macOS, Windows and a browser version): scenes with game / screen / window capture, webcam with beauty, logo, text, chat and gift/follow alerts on the stream, and an audio mixer (mic with noise suppression and voice effects, game sound, music). It goes live straight into a room through the SDK (WebRTC, no OBS, no RTMP) as a normal publisher. It signs in with an app token from your server (a room claim locks the room).
- Measured (M-series Mac, development server): the viewer decodes 720p60 at 59.8–60 fps; studio → viewer 40–59 ms (median); the studio uses ~4–5% of a 16-core machine at 720p60 with simulcast (software H.264).
- Room messages it understands: topic
chat(text or{"text","name"}),gift({"gift","count","name","coins"}),follow({"name"}) — send them withsendRoomMessage(text, topic). - Builds are unsigned betas (macOS Gatekeeper and Windows SmartScreen warn). The Windows installer has not been run on Windows yet, 1080p60 and the macOS system sound on a real game are not measured. Ask us for a build.
Karaoke catalogue PREVIEW
The songs the karaoke engine lists and sings: the house songs plus your project's own. A song is a backing track (mp3, m4a, ogg or wav) + LRC lyrics (line and word timing) + an optional reference melody (JSON or MIDI) for the score. Files are never public: the API returns signed URLs (6 h) that a player can stream with Range requests.
Authentication: a user token (Authorization: Bearer … or ?token=). Each song: id, title, artist, language, durationMs, license, tags, lines, wordTimed, melodyNotes, owner (house | app), trackUrl, lrcUrl, melodyUrl. KaraokeCatalogue in @ilorb/signal-web-fx wraps both calls. Adding your own songs goes through us for now (we check the licence); the console lists them under Develop → Karaoke catalogue.
Callbacks
Set a callback URL (https) and generate a callback secret in Project → Callbacks. ILORB Signal then sends a POST to your server on each event. Callbacks are sent only when both URL and secret are set.
| Event | Extra fields | When |
|---|---|---|
room.started | — | The first person joined a room. |
user.joined | userId, participants, role? | Someone joined. role: "viewer" only for a viewer. |
user.left | userId, reason, participants, role? | Someone left. reason: closed (left or connection dropped), error, replaced, kicked, room_closed, insufficient_credit, app_disabled. |
room.ended | startedAt | The last person left. |
balance.low | balanceSeconds, thresholdSeconds | The account balance dropped below the low-balance alert set in Billing. Sent once per crossing, by the room that crossed it. |
gift.purchased | orderId, giftId, name, animationUrl, from, to, amountCents, currency, viewers | A live gift was paid and delivered into the room. Sent once per order. |
recording.started | recordingId | A cloud recording started. |
recording.stopped | recordingId, seconds | It stopped (by stop or because the room emptied). The last chunk is uploaded right after. |
recording.segment_ready | recordingId, file, bytes, durationSeconds | A chunk was stored and can be downloaded now with GET /v1/rooms/{roomId}/recordings/{recordingId}/{file}. Copy it to your storage if you keep recordings longer than the project's retention. The last one arrives right after recording.stopped. |
recording.person.started | recordingId, userId | A per-person recording started. |
recording.person.stopped | recordingId, userId, seconds, reason | It stopped: stopped, left (gone for 60 s) or room_empty. |
player.started | url, durationMs, hasVideo, hasAudio | The cloud player started playing (also when a new URL replaces the current one). |
player.paused / player.resumed / player.seeked | positionMs | Paused, resumed or moved. |
player.stopped / player.ended | reason / durationMs | Stopped (stop, room empty) or reached the end of the file. |
moderation.flagged / moderation.user_banned / moderation.stream_taken_down | flagId, userId, roomId, kind, score, reason, matched, transcript, status, action (+ until) | A content moderation violation. status: pending waits for your decision (POST /v1/moderation/flags/{flagId}/decision). |
moderation.decided | flagId, userId, roomId, kind, decision, status, action | A flag was decided (by you, the console or automatically). |
moderation.check | checkId, userId, roomId, kind, contentType, bytes, mediaUrl, mediaBase64? | Webhook mode: answer { "violation": true | false } in the response or later. |
chat.push, chat.message.review, chat.message.sent, chat.message.reported, chat.call.created, chat.call.ended | see chat push and chat server | From the chat (no roomId). |
Every body has event, eventId, appId and at (room events also roomId). The typed payload of each event is CallbackEvent in @ilorb/signal-server/callback. New events may be added: answer 2xx to events you don't know.
POST https://your-server.com/ilorb/callback
x-ilorb-event: user.joined
x-ilorb-event-id: evt_4f1c2a9e0b7d3c5a8e6f1b2c
x-ilorb-timestamp: 1760965200
x-ilorb-signature: sha256=5d1c…
{ "event": "user.joined", "eventId": "evt_4f1c2a9e0b7d3c5a8e6f1b2c", "appId": "app_…", "roomId": "room-42",
"at": "2026-10-20T13:00:00.000Z", "userId": "42", "participants": 3 }
Verify the signature
The signature is sha256= + hex(HMAC-SHA256(callback secret, timestamp + "." + raw body)). Always verify it on the raw body, and reject old timestamps to block replays.
import crypto from 'node:crypto'
app.post('/ilorb/callback', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.header('x-ilorb-timestamp')
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.ILORB_CALLBACK_SECRET)
.update(ts + '.' + req.body).digest('hex')
const got = req.header('x-ilorb-signature') ?? ''
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300
if (!fresh || got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)))
return res.sendStatus(401)
const event = JSON.parse(req.body)
// …
res.sendStatus(200)
})
Handle each event once
eventId (also the x-ilorb-event-id header) is unique per event. Remember the ids you handled for at least 10 minutes (twice the 300 s window) and answer 2xx without acting again when one comes back: a captured delivery replayed within the window, or the same event handled by two of your servers. Anything older is refused by its timestamp. Deliveries are not ordered: each one is its own request, so user.joined can arrive before room.started, or room.ended before the last user.left; order by at (apply a change only if it is newer than the last one you applied for that user or room). In Node, CallbackReceiver does the three checks:
import { CallbackReceiver } from '@ilorb/signal-server/callback'
const receiver = new CallbackReceiver({ secret: process.env.ILORB_CALLBACK_SECRET }) // a list while rotating; store: your Redis/DB with several servers
const r = await receiver.receive(req.headers, rawBody)
if (!r.ok) return res.status(401).json({ error: r.reason }) // bad_signature | stale_timestamp | missing_headers | bad_timestamp | bad_json
if (r.duplicate) return res.json({}) // already handled
try { res.json(await handle(r.event)) } catch (e) { await receiver.forget(r.eventId); res.sendStatus(500) }
A complete sample server that issues tokens and receives every callback (keeps who is in each room, copies recording chunks, answers moderation checks, decides flags, hands chat.push to FCM/APNs) is examples/web/server.mjs + examples/web/callbacks.mjs in the SDK repository.
Answer with any 2xx within 5 seconds. Deliveries are not retried; every delivery (status and time) is listed in Project → Callbacks → Recent deliveries. Treat callbacks as notifications and use the REST API as the source of truth.
Device profiles
Phones differ a lot: some play voice too low, some route audio oddly, some leave echo behind, and entry-level phones need a lighter audio chain. ILORB Signal tunes this for you, from our servers. The SDK asks for its profile when the app opens and applies it; nothing ships in your app, and there is nothing to configure per device in the console.
- Layers. The profile is merged key by key, from the most general to the most specific: default › device class › brand › model prefix (
moto g*) › exact model. - Device class. The SDK sends the hardware (RAM, total and fast CPU cores, max frequency, chip) and the server classifies the phone as low, mid or high end, with rules kept on the server. Low-end phones get a lighter chain automatically.
- About 188 SDK settings plus the native audio settings can change this way: no new build and no store update. The new profile is used the next time the app opens.
- Echo per model (calibrating). The SDK measures echo on each phone with a detector that only measures and never changes the audio. The server will use those measurements to choose echo cancellation per model; today it only observes.
{ "ok": true, "lang": "en", "profile": { "id": 1, "name": "default + medium + Motorola", "deviceClass": "medium",
"layers": [ … ], "settings": { "playbackGain": 1.25, "quality": { … }, "tuning": { "sdk": { … }, "native": { … }, "app": { … } }, "sound": { … } } } } // or "profile": null
The SDK calls this for you. Without v=2 the endpoint answers as before (only the most specific profile), so older app versions keep working. Missing settings mean "use the default". Missing brand or model → 400 { ok: false, error: 'brand_and_model_required' }. If a phone model misbehaves in your app, tell us: we fix it on the server for every app at once.
en=1 (sent by the current SDK) returns the profile with English names and lang: 'en':
| Field | Contents |
|---|---|
profile.deviceClass | 'weak' | 'medium' | 'good', or null when the class is unknown. |
settings.quality | Audio quality keys: kbps, red, dtx, aec, ns, agc, opusPlc, stereo, echoResidual, echoResidualMaxDb, sdkAgc, highPass, howlingSuppression, noise, noiseStrengthDb, outputGain, micGainDb, clarity, route ('media' | 'call'), source ('mic' | 'voice' | 'recognition' | 'raw'), hwEcho, hwNoise, sampleRate, receiveOverTcp, speakerGraceS, networkWarning, networkWarningLossPct, musicDetector, noiseActions, iosMode, iosOptions; echoResidual: 'simple'. |
settings.tuning | { sdk, native, app }: SDK settings, native audio settings, and free keys your app reads (voice.deviceProfile?.appTuning). |
settings.sound | Sound-recognition rules: { music, loud, pauses, thresholds }. |
Automatic device profile (React Native)
With @ilorb/signal-react-native-audio imported, VoiceClient does all of this by itself — no code: it sends the device identity and hardware (RAM, cores, CPU clock, chip) with v=2, so the server picks the device class (weak / medium / good) and merges the layers default › class › brand › model › user; it caches the profile on the device and applies it on the next launch before the network answers; it applies the audio quality, the SDK tuning and the native tuning; it asks again every 20 s while the app is in the foreground; and it sends CPU, network and echo measurements with the session stats. On Android add one line to MainApplication.onCreate, after LiveKitReactNative.setup: com.ilorb.signal.audio.IlorbCaptureConfig.applyAtStartup(this) (microphone source, sample rate and hardware effects from the profile).
import '@ilorb/signal-react-native-audio' // registers the platform
const voice = new VoiceClient({ serverUrl, webrtc })
voice.on('deviceProfileChanged', (c) => console.log(c.changedKeys)) // already applied
voice.on('needsRestart', (keys) => askToRestartAudio()) // route, mic source… apply on the next launch
voice.deviceProfile?.appTuning // free keys your app reads (settings.tuning.app)
// opt out, or keep the quality in your own UI
new VoiceClient({ serverUrl, webrtc, deviceProfile: false })
new VoiceClient({ serverUrl, webrtc, deviceProfile: { applyQuality: false } })
The same VoiceClient also recovers a microphone the system silenced while the app was in the background (captureRecovery, on by default; getConnectionInfo().captureReopens). iOS: the profile, quality, tuning, native storage and the device stats (CPU, thermal state, memory, battery, network) work; the silent-capture guard is Android only for now.
Engine API
@ilorb/signal-engine gives you IlorbEngine: a singleton room engine with named events, plain argument objects and numeric enums for voice rooms (log in, go on stage, play, sound levels, room messages). It runs on top of VoiceClient; you create it once and your room screens talk only to the engine.
loginRoom, startPublishingStream, roomStreamUpdate… one engine, events by name, stable enum numbers.1. Install
npm install @ilorb/signal-engine @ilorb/signal @livekit/react-native @livekit/react-native-webrtc
npm install @ilorb/signal-server # on your server
2. Issue tokens on your server
The App Secret never goes into the app: it stays on your server, which creates a short-lived user token per user. Expose it from an authenticated endpoint of your API.
3. Create the engine
Create the engine once, with your server URL, a token callback and the WebRTC implementation. Everything after that talks to IlorbEngine.instance().
import { IlorbEngine } from '@ilorb/signal-engine'
import { RTCPeerConnection, mediaDevices, MediaStream } from '@livekit/react-native-webrtc'
await IlorbEngine.createEngineWithProfile({
serverUrl: 'https://api-signal.ilorb.app',
getToken: (roomID, userID) => myApi.voiceToken(roomID, userID), // your server
webrtc: { RTCPeerConnection, mediaDevices, MediaStream },
})
const engine = IlorbEngine.instance()
engine.on('roomStreamUpdate', (roomID, type, streams) => {
/* your handler */
})
await engine.loginRoom(roomID, { userID, userName })
await engine.startPublishingStream(`${roomID}-${userID}`)
The enums (IlorbUpdateType, IlorbRemoteDeviceState, IlorbPublisherState, IlorbPlayerState, IlorbRoomStateChangedReason, IlorbRoomState) are exported by @ilorb/signal-engine.
Profile options
| Option | Default | Description |
|---|---|---|
serverUrl | — | https://api-signal.ilorb.app |
getToken(roomID, userID) | — | Async callback returning a user token from your server. Optional if you pass config.token to loginRoom. |
webrtc, audio, hooks | Passed to VoiceClient. | |
soundLevelIntervalMs | 400 | Interval of the sound-level events. |
streamIdOf(roomID, userID) | {roomID}-{userID} | The streamID everyone sees for a user. The default is also the ILORB Signal track name. |
userIdOfStream(roomID, streamID) | strips {roomID}- | Inverse of streamIdOf; return null for "not a stream of this room". |
autoPlay | true | true: you hear everyone on stage and stopPlayingStream silences one. false: a stream is silent until you call startPlayingStream. |
Call by call
| Call | What happens in ILORB Signal |
|---|---|
createEngineWithProfile(profile)instance()destroyEngine() | Supported Same singleton. The profile takes { serverUrl, getToken, webrtc }; no secret goes into the app. destroyEngine logs out and drops every listener. |
loginRoom(roomID, user, config?) | Supported Joins the room with a token from config.token or your getToken. Resolves to { errorCode, extendedData }. user.userID must match the token's sub. |
logoutRoom(roomID?) | Supported Leaves the room (one room at a time) and fires roomStateChanged(…, 7). |
startPublishingStream(streamID)stopPublishingStream() | Supported Goes on / off stage. Others see the stream as streamIdOf(roomID, you). |
muteMicrophone(mute)isMicrophoneMuted() | Supported A muted microphone sends zero packets; you stay on stage and unmute instantly. |
muteSpeaker(mute)isSpeakerMuted() | Supported Silences the whole room on this device (volume only). |
startPlayingStream(streamID)stopPlayingStream(streamID) | Supported Volume only: the voice keeps arriving and comes back instantly. playerStateUpdate fires as usual. |
mutePlayStreamAudio(streamID, mute)setPlayVolume(streamID, 0–200) | Supported Per-user volume, 100 = normal. |
setAllPlayStreamVolume(0–200)muteAllPlayStreamAudio(mute) | Supported Applied to everyone in the room. |
sendBroadcastMessage(roomID, message) | Supported Text to the room, received as IMRecvBroadcastMessage. messageID is a local counter. |
sendCustomCommand(roomID, command, users) | Supported Received as IMRecvCustomCommand. Sent to the room and filtered by the receiving SDK: not private. |
startSoundLevelMonitor(config?)stopSoundLevelMonitor() | Supported Turns remoteSoundLevelUpdate / capturedSoundLevelUpdate on and off. The interval comes from soundLevelIntervalMs; config.millisecond is ignored. |
on(event, cb)off(event, cb?) | Supported Same event names and arguments (table below). |
setAudioRouteToSpeakeruseAudioDeviceenableAudioCaptureDevicesetRoomScenario | No-op Resolve without doing anything: audio routing belongs to the OS and your audio session (hooks.prepare), the scenario to the audio options. |
renewToken(roomID, token) | No-op Logs a warning. Issue tokens that last the session (up to 7 days) or call loginRoom again. |
Video: startPreview, camera calls | Not available The engine is voice only. Use VoiceClient directly for video. |
| Stream mixing, CDN relay, media player | Not available Not on the engine. PK uses linked rooms instead of a mixer; a URL for everyone in the room is the cloud player (server API). |
| Stream extra info, room extra info | Not available Not on the engine yet; extraInfo in roomStreamUpdate is always ''. Room and member state (seats, topic) can use attributes through engine.getVoiceClient(). |
Anything that is not on the engine is still reachable through engine.getVoiceClient(): muteUser, setReceiving, resume, getNetworkStats.
| Event | Arguments | Fired when |
|---|---|---|
roomStateChanged | roomID, reason, errorCode, extendedData | 0 Logining · 1 Logined · 2 LoginFailed · 3 Reconnecting · 4 Reconnected · 6 KickOut (kicked, replaced, room closed, out of credit) · 7 Logout. The legacy roomStateUpdate (0/1/2) fires too. |
roomUserUpdate | roomID, Add=0 | Delete=1, [{userID, userName}] | Someone joined or left (never yourself). Also roomOnlineUserCountUpdate. |
roomStreamUpdate | roomID, Add | Delete, [{user, streamID, extraInfo}], '' | Someone went on or off stage. |
remoteMicStateUpdate | streamID, Open=0 | Disable=9 | Mute=10 | No track = Disable; on stage with a closed mic = Mute. |
remoteSoundLevelUpdate | { [streamID]: 0–100 } | Every interval, while the monitor is on. |
capturedSoundLevelUpdate | 0–100 | Your microphone, while publishing and the monitor is on (0 when muted). |
publisherStateUpdate | streamID, NoPublish=0 | PublishRequesting=1 | Publishing=2, errorCode, '' | Your stage state. |
playerStateUpdate | streamID, NoPlay=0 | PlayRequesting=1 | Playing=2, errorCode, '' | For streams you started playing. |
IMRecvBroadcastMessage | roomID, [{message, messageID, sendTime, fromUser}] | Someone called sendBroadcastMessage. sendTime is the receive time. |
IMRecvCustomCommand | roomID, fromUser, command | A sendCustomCommand addressed to you (or to everyone). |
Good to know
- Tokens come from your server. No secret in the app;
getTokenis called on everyloginRoom. - Playing is volume-only. Everyone on stage is received;
startPlayingStream/stopPlayingStreamonly change the volume, so it is instant and never breaks listening. To really stop receiving (and egress), useengine.getVoiceClient().muteUser(userId, true)orsetReceiving(false). - Stream IDs are derived from the user with
streamIdOf, not chosen freely on each publish. - The engine is voice only. For video use the
VoiceClientdirectly (startCamera/getVideoStream); PK uses linked rooms instead of a mixer. No CDN relay, media player or stream/room extra info yet. - Room members are always notified when someone joins or leaves.
Engine error codes
Each ILORB Signal error code becomes a numeric engine errorCode. The original code always goes in extendedData, so you can still tell "out of credit" from "kicked".
| ILORB Signal | Engine errorCode | Name |
|---|---|---|
invalid_token | 1002033 | RoomErrorAuthenticationFailed |
insufficient_credit | 1000039 | CommonAppAccessDeniedError |
app_disabled | 1000037 | CommonAppOfflineError |
room_full | 1002034 | RoomErrorExceedMaximumMember |
replaced | 1002050 | RoomKickedOut |
kicked | 1002055 | RoomManualKickedOut |
room_closed | 1002052 | RoomDisconnect |
network | 1002030 | RoomErrorConnectFailed |
internal / unknown | 1002099 | RoomInnerError |
getToken failed or empty | 1002067 | RoomNoToken |
token sub ≠ userID | 1002077 | RoomTokenUseridError |
| not logged in | 1000002 | CommonNotLoginRoom |
At login these come back in loginRoom's result and in roomStateChanged(…, 2); during a session, terminal codes arrive as roomStateChanged(roomID, 6, errorCode, code).
RTC API
The RTC API is a channel-style API on top of VoiceClient: @ilorb/signal-rtc-web for the browser, @ilorb/signal-rtc-react-native for React Native, ilorb-signal-rtc-android for Android (package com.ilorb.signal.rtc) and IlorbRtcKit for iOS. Clients and engines join channels with a numeric uid, publish tracks and report everything through named events, string states and numeric enums that are the same on every platform.
IlorbRTC.createClient, join, publish, user-published… React Native: createIlorbRtcEngine, joinChannel, onUserJoined, RtcSurfaceView… Android and iOS: RtcEngine / IlorbRtcEngineKit with the same callbacks and constant values.RtcTokenBuilder.buildTokenWithUid(appId, appCertificate, channel, uid, role, expire, expire) in Node, Go, Java, PHP and Python — it issues an ILORB token.examples/rtc-*) run in our end-to-end tests: two browsers (Web), and React Native, native Android and native iOS apps talking to a browser: voice, mute, music, messages, roles, token renewal, video both ways.1. Install
# Web
npm install @ilorb/signal-rtc-web @ilorb/signal
# React Native
npm install @ilorb/signal-rtc-react-native @ilorb/signal @livekit/react-native @livekit/react-native-webrtc
npm install @ilorb/signal-react-native-audio # optional: music (startAudioMixing), mic gain
# Android (Gradle)
implementation "com.ilorb.signal:ilorb-signal-rtc-android:0.1.0"
# iOS (Swift Package) — product IlorbRtcKit
.package(path: "…/packages/ios-swift-rtc")
# your server
npm install @ilorb/signal-server
2. Issue tokens on your server
appId is your ILORB Signal App ID and appCertificate your App Secret; the channel is the room and the uid the user. The result is a regular user token.
import { RtcTokenBuilder, RtcRole } from '@ilorb/signal-server/rtc-token'
const token = RtcTokenBuilder.buildTokenWithUid(appId, appCertificate, channel, uid, RtcRole.PUBLISHER, 3600, 3600)
| Language | Import | Call |
|---|---|---|
| Node / Workers / Deno | @ilorb/signal-server/rtc-token | RtcTokenBuilder.buildTokenWithUid (synchronous) |
| Go | signal.ilorb.com/server/rtctokenbuilder | BuildTokenWithUid, RolePublisher |
| Java | com.ilorb.signal.server.RtcTokenBuilder2 | buildTokenWithUid, Role.ROLE_PUBLISHER |
| PHP | IlorbSignal\RtcTokenBuilder2 | buildTokenWithUid, ROLE_PUBLISHER |
| Python | ilorb_signal.rtc_token | RtcTokenBuilder.build_token_with_uid, Role_Publisher |
tokenExpire is seconds from now (0 = 24 h; a Unix timestamp also works), from 60 s up to 7 days; with privilegeExpire the shorter one wins. role is accepted and ignored. uid 0 ("any user") does not exist: every ILORB token is for one user. buildTokenWithUserAccount takes a string account (up to 40 characters [0-9A-Za-z_-]).
3. Join from the browser
import IlorbRTC, { configureIlorb } from '@ilorb/signal-rtc-web'
configureIlorb({ serverUrl: 'https://api-signal.ilorb.app' }) // optional
const client = IlorbRTC.createClient({ mode: 'rtc', codec: 'vp8' })
client.on('user-published', async (user, type) => {
await client.subscribe(user, type)
type === 'audio' ? user.audioTrack.play() : user.videoTrack.play(el)
})
await client.join(appId, channel, token, uid)
const mic = await IlorbRTC.createMicrophoneAudioTrack()
await client.publish(mic)
appId is your ILORB Signal App ID. configureIlorb is only needed off production; without it the package uses https://api-signal.ilorb.app (or globalThis.ILORB_SIGNAL_SERVER_URL).
React Native
import createIlorbRtcEngine, { ChannelProfileType, ClientRoleType, RtcSurfaceView, configureIlorb } from '@ilorb/signal-rtc-react-native'
import { IlorbMediaPlayer, IlorbAudio } from '@ilorb/signal-react-native-audio' // optional
configureIlorb({ mediaPlayer: IlorbMediaPlayer, audioProcessing: IlorbAudio }) // optional, once
const engine = createIlorbRtcEngine()
engine.initialize({ appId, channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting })
engine.registerEventHandler({ onJoinChannelSuccess, onUserJoined, onUserOffline, onAudioVolumeIndication })
engine.joinChannel(token, channel, uid, { clientRoleType: ClientRoleType.ClientRoleBroadcaster, publishMicrophoneTrack: true })
// video: <RtcSurfaceView canvas={{ uid }} /> (uid 0 = me)
Enums (ChannelProfileType, ClientRoleType, ConnectionStateType, ConnectionChangedReasonType, ErrorCodeType, RemoteAudioState, AudioProfileType…) have fixed numeric values, the same on Android and iOS. Methods return 0 or a negative ErrorCodeType, and the result arrives as an event.
uid ↔ user ID. A numeric ILORB user ID (1 to 2³²−1) is the uid. Any other user ID (e.g. "ana", from a web app that joined with a string uid) becomes a stable 32-bit number; engine.getUserIdOfUid(uid) gives the original back. Issue tokens with numeric uids and you never see the difference.
Android (Kotlin or Java)
RtcEngine, RtcEngineConfig, IRtcEngineEventHandler, ChannelMediaOptions, Constants, VideoCanvas and VideoEncoderConfiguration live in com.ilorb.signal.rtc and run on the Kotlin VoiceClient. IRtcEngineEventHandler is a Java class, so your overrides compile with either nullability.
import com.ilorb.signal.rtc.*
val config = RtcEngineConfig()
config.mContext = applicationContext
config.mAppId = "your ILORB Signal App ID"
config.mEventHandler = handler
config.mServerUrl = "https://api-signal.ilorb.app" // this is the default
config.mTokenProvider = TokenProvider { channel, uid -> myApi.token(channel, uid) } // optional
val engine = RtcEngine.create(config)
engine.enableAudioVolumeIndication(200, 3, true)
val options = ChannelMediaOptions()
options.clientRoleType = Constants.CLIENT_ROLE_BROADCASTER
options.publishMicrophoneTrack = true
engine.joinChannel(token, "room-1", 1001, options)
Callbacks arrive on the main thread; methods return the error code right away and the effect runs in order. mLicense, mAreaCode, mLogConfig and extensions are accepted and ignored.
iOS (Swift)
IlorbRtcEngineKit, IlorbRtcEngineDelegate, IlorbRtcEngineConfig, IlorbRtcChannelMediaOptions, IlorbRtcVideoCanvas, IlorbVideoEncoderConfiguration and the enums keep the same raw values as the other platforms. Conform with extension MyVC: IlorbRtcEngineDelegate { … }. It runs on the Swift VoiceClient.
import IlorbRtcKit
let config = IlorbRtcEngineConfig()
config.appId = "your ILORB Signal App ID"
config.channelProfile = .liveBroadcasting
config.serverUrl = "https://api-signal.ilorb.app" // this is the default
config.tokenProvider = { channel, uid in await myApi.token(channel, uid) } // optional
rtcKit = IlorbRtcEngineKit.sharedEngine(with: config, delegate: self)
rtcKit.enableAudioVolumeIndication(200, smooth: 3, reportVad: true)
let options = IlorbRtcChannelMediaOptions()
options.clientRoleType = .broadcaster
options.publishMicrophoneTrack = true
rtcKit.joinChannel(byToken: token, channelId: "room-1", uid: 1001, mediaOptions: options)
Add NSMicrophoneUsageDescription (and NSCameraUsageDescription for video) and UIBackgroundModes → audio to your Info.plist. startAudioMixing plays local files (wav, mp3, m4a/aac, caf, aiff).
Native uid ↔ user ID. Same rule as React Native: a numeric user ID that fits in 32 bits is the uid ("1001" ↔ 1001); any other ID becomes a stable 32-bit FNV-1a hash with bit 31 set ("ana" ↔ 0x9429CA85 on iOS, -1809200507 on Android); onUserInfoUpdated / didUpdatedUserInfo gives the original string. Roles: with the default LIVE_BROADCASTING profile only hosts show up (onUserJoined), and a host who steps down leaves with USER_OFFLINE_BECOME_AUDIENCE; with COMMUNICATION everyone shows up.
Call by call
| Call (Web) | What happens in ILORB Signal |
|---|---|
IlorbRTC.createClient({ mode, codec }) | Supported rtc and live. codec vp8/h264 becomes the preferred video codec. |
client.join(appId, channel, token, uid) | Supported Joins room channel. The token is required; appId and uid must match it. uid = null takes the user from the token. Resolves to the uid in the type you used (number or string). |
client.leave() | Supported Fires connection-state-change to DISCONNECTED with reason LEAVE. Local tracks stay open. |
createMicrophoneAudioTrackcreateCameraVideoTrackcreateMicrophoneAndCameraTracks | Supported Opens the device right away (preview before joining). microphoneId, AEC/ANS/AGC, cameraId, facingMode and encoderConfig presets 180p_1…1080p_1 or { width, height, frameRate }. |
client.publish(tracks)client.unpublish(tracks?) | Supported One audio and one video track at a time. Unpublishing keeps the local preview. |
track.setEnabled(bool)track.setMuted(bool) | Supported Both mute: a muted microphone or camera sends zero packets; others get user-info-updated (mute-audio/mute-video) and user-unpublished. |
videoTrack.play(element, { mirror })track.stop() · track.close() | Supported Renders a <video> inside the element. close releases the device. |
client.subscribe(user, type)client.unsubscribe(user, type?) | Supported ILORB subscribes automatically; subscribe returns the remote track (or data channel). Unsubscribing video stops receiving it; audio becomes silent. |
remoteAudioTrack.play() / stop() / setVolume(0–100) | Supported Volume only: remote audio already plays; stop silences it. |
client.setClientRole(role) | Supported In live mode only hosts publish and only hosts are seen by others: an audience member is not in remoteUsers; a host who becomes audience leaves with reason BecomeAudience. |
client.enableAudioVolumeIndicator() | Supported volume-indicator every 2 s with [{ uid, level 0–100 }] for everyone who spoke, you included. |
client.renewToken(token) | Supported token-privilege-will-expire 30 s before the token's exp; the new token is used on reconnection. token-privilege-did-expire at exp if not renewed. |
network-quality, getNetworkQuality() | Supported Every 2 s, uplink/downlink 0–6 from packet loss and round trip. getRemoteNetworkQuality reports your downlink for everyone. |
client.sendStreamMessage(data), stream-messagepublish({ id }) data channels | Supported Through the room's WebSocket: ordered and reliable, up to 1 KB (4 KB on data channels). Interoperates with sendStreamMessage of the React Native, Android and iOS RTC packages. |
enableDualStream / setDualStreamModesetRemoteVideoStreamType / setRemoteDefaultVideoStreamType | Supported Turns on simulcast; the low layer is half the width (setLowStreamParameter is accepted and ignored). |
client.remoteUsers, uid, channelName, connectionState, localTracks, getDevices… | Supported Same shapes. |
setLogLevel, log upload | No-op |
| Encryption config, cloud proxy, CDN live streaming, channel media relay, content inspection | Not available Linked rooms replace channel media relay; cloud recording has its own API. |
| Call (React Native) | What happens in ILORB Signal |
|---|---|
createIlorbRtcEngine() · initialize · release | Supported Singleton. appId must match the token. |
registerEventHandler · addListener | Supported Same callback names and arguments (list below). |
joinChannel(token, channel, uid, ChannelMediaOptions) · leaveChannel · updateChannelMediaOptions | Supported clientRoleType, publishMicrophoneTrack, publishCameraTrack, autoSubscribeAudio/Video, channelProfile, token. |
setClientRole · setChannelProfile | Supported Live broadcasting: audience does not publish and is invisible to others (onUserOffline(…, BecomeAudience)). |
enableAudio / disableAudio / enableLocalAudio / muteLocalAudioStream | Supported A muted microphone sends zero packets. |
muteRemoteAudioStream / muteAllRemoteAudioStreams | Supported Really unsubscribes (you stop receiving and paying for it). |
adjustRecordingSignalVolume / adjustPlaybackSignalVolume / adjustUserPlaybackSignalVolume | Supported Recording volume becomes mic gain with @ilorb/signal-react-native-audio (0 mutes). |
enableAudioVolumeIndication(interval, smooth, vad) | Supported Two onAudioVolumeIndication per interval: you (uid 0) and the 3 loudest others, 0–255. onActiveSpeaker. |
startAudioMixing · pause/resume/stopAudioMixing · adjustAudioMixingVolume · position | Supported Through ILORB's music player (mixed into what you send, no echo cancellation on it). loopback = true returns −4. |
setAudioProfile · setAudioScenario | Supported Bitrate and stereo from the audio profile table; music scenarios turn off noise suppression and AGC. |
createDataStream · sendStreamMessage | Supported Up to 5 streams, 1 KB per message, ordered and reliable; Web, Android and iOS apps in the same room receive them. sentTs is the arrival time. |
renewToken | Supported onTokenPrivilegeWillExpire 30 s before exp, onRequestToken at exp. |
enableVideo · startPreview · muteLocalVideoStream · switchCamera · setVideoEncoderConfiguration · enableDualStreamMode · setRemoteVideoStreamType · muteRemoteVideoStream | Supported <RtcSurfaceView canvas={{ uid }} /> renders with RTCView. Landscape encoder dimensions become portrait. |
setEnableSpeakerphone · setDefaultAudioRouteToSpeakerphone | Your hook Pass configureIlorb({ audioRoute }) (e.g. AudioSession.selectAudioOutput of @livekit/react-native). |
Screen capture through the engine, media relay, RTMP transcoding, encryption, extensions, spatial audio, createMediaPlayer, RtcEngineEx (multi-channel), custom sources, raw frame observers | Not available Screen sharing: @ilorb/signal-react-native-screen. |
Call (Android RtcEngine / iOS IlorbRtcEngineKit) | What happens in ILORB Signal |
|---|---|
create(config) · sharedEngine(with:delegate:) · destroy | Supported One engine per app. |
joinChannel(token, channel, uid, options) · joinChannel(byToken:…) · …WithUserAccount · leaveChannel | Supported uid 0 = the token's user; a non-zero uid or account must match it (ERR_INVALID_USER_ID). |
setChannelProfile · setClientRole · updateChannelMediaOptions / updateChannel(with:) | Supported Publishes or stops the mic (and the camera after enableVideo). |
enableAudio / disableAudio · enableLocalAudio · muteLocalAudioStream | Supported enableLocalAudio(false) keeps you on stage with the mic closed. Muted = zero packets. |
muteRemoteAudioStream · muteAllRemoteAudioStreams | Supported Really unsubscribes. |
adjustRecordingSignalVolume · adjustPlaybackSignalVolume · adjustUserPlaybackSignalVolume | Supported Mic gain (voice only), output gain, per-user volume. |
enableAudioVolumeIndication | Supported Local (uid 0) and remote callbacks, 0–255. smooth is ignored. |
setEnableSpeakerphone · isSpeakerphoneEnabled · setDefaultAudioRouteToSpeakerphone | Supported |
setAudioProfile · setAudioScenario · setAudioEffectPreset · setVoiceConversionPreset | Supported Unmapped presets return -ERR_NOT_SUPPORTED. |
startAudioMixing · pause/resume/stopAudioMixing · mixing volumes and position | Supported ILORB's music player; it goes out with the published mic. |
renewToken · onTokenPrivilegeWillExpire · onRequestToken | Supported VoiceClient.renewToken: reconnections use the new token. |
createDataStream · sendStreamMessage | Supported 5 streams, 1 KB per message, reliable and ordered; Web and React Native apps in the room receive them. |
enableVideo · setupLocalVideo / setupRemoteVideo · muteLocalVideoStream · switchCamera · setVideoEncoderConfiguration · enableDualStreamMode · setRemoteVideoStreamType | Supported The camera starts when you join as a host (a preview before joining appears after the join). |
setParameters · log settings · setLocalRenderMode / setRemoteRenderMode | No-op Accepted, return 0. |
Sound effects (playEffect), media player, raw frame observers, custom sources / pushExternal*, screen capture through the engine, media relay, RTMP/CDN transcoding, cloud proxy, encryption, spatial audio, extensions, content inspect, snapshots, local recording, RtcEngineEx (multi-channel), echo test, last-mile probe, voice beautifier presets other than off, multiple cameras | Not available Returns -ERR_NOT_SUPPORTED or the callback never fires. Screen sharing: VoiceClient.startScreenShare; channel media relay: linked rooms. iOS: no in-ear monitoring yet. |
| ILORB Signal | Native onError | onConnectionStateChanged reason |
|---|---|---|
| invalid / missing token | ERR_INVALID_TOKEN (110) | INVALID_TOKEN (8) |
token_expired | ERR_TOKEN_EXPIRED (109) + onRequestToken | TOKEN_EXPIRED (9) |
| token from another App ID · another channel | ERR_INVALID_APP_ID (101) · ERR_INVALID_CHANNEL_NAME (102) | INVALID_APP_ID (6) · INVALID_CHANNEL_NAME (7) |
| uid ≠ token user | ERR_INVALID_USER_ID (121) | REJECTED_BY_SERVER (10) |
insufficient_credit, app_disabled, room_full, room_closed | ERR_JOIN_CHANNEL_REJECTED (17) | REJECTED_BY_SERVER (10) |
kicked | ERR_CLIENT_IS_BANNED_BY_SERVER (123) | BANNED_BY_SERVER (3) |
replaced (same user joined elsewhere) | — | SAME_UID_LOGIN (19) |
| Event | Arguments | Fired when |
|---|---|---|
user-joined / user-leftonUserJoined / onUserOffline | user · user, "Quit" | "BecomeAudience"connection, uid · connection, uid, Quit=0 | BecomeAudience=2 | Someone entered or left (never yourself). |
user-published / user-unpublished / user-info-updatedonUserMuteAudio · onRemoteAudioStateChanged · onRemoteVideoStateChanged | user, "audio" | "video"connection, uid, state, reason, elapsed | A microphone or camera opened, muted or closed. |
volume-indicator · onAudioVolumeIndication | [{ uid, level }] · connection, speakers, speakerNumber, totalVolume | Every interval after enabling it. |
connection-state-change · onConnectionStateChanged | cur, prev, reason? · connection, state, reason | Connecting, connected, reconnecting, left, or ended by the server (UID_BANNED/BannedByServer, CHANNEL_BANNED/RejectedByServer, UID_CONFLICT/SameUidLogin, TOKEN_EXPIRE/InvalidToken). |
token-privilege-will-expire / -did-expire · onTokenPrivilegeWillExpire / onRequestToken | 30 s before / at the token's exp. | |
network-quality · onNetworkQuality, onRtcStats | uplink/downlink 0–6 · connection, 0, tx, rx | Every 2 s. |
stream-message · onStreamMessage | uid, payload · connection, uid, streamId, data, length, sentTs | A data-stream message arrived. |
exception · onError | { code: -1, msg: "ilorb:<code>: …" } · ErrorCodeType, "ilorb:<code>: …" | Any other error from the room (the ILORB code is in the message). |
Good to know
- The token is required and carries app, room and user. There is no "testing mode" with only an App ID, and no uid-0 token.
- User IDs are strings in ILORB Signal. Web: numeric uids come back as numbers when you joined with a number. React Native: see uid ↔ user ID above.
- Remote audio plays automatically. Web
play()/stop()control volume, so a lateplay()never misses the first words. - Messages (
sendStreamMessage, data channels) go through the room's WebSocket: reliable and ordered; every member receives them — Web, React Native, Android and iOS use the same format (topicrtc:stream:<id>, base64 body). - Channel names are
[0-9A-Za-z_-], up to 64 characters. Names with spaces or symbols return-ERR_INVALID_CHANNEL_NAME. - Network quality is reported for you (uid 0) only, not per remote user.
- Live hosts who have not published yet: Web and React Native also show a host marked by the
rtc.role=hostattribute; native Android and iOS show a host once it publishes.
Error codes
Errors reach the app through the error event. When the server ends a session for a business reason, the WebSocket closes with code 4001 and the error code as the reason. All codes below are terminal: the SDK does not reconnect.
| Code | Meaning | What to do |
|---|---|---|
invalid_token | Missing, expired, wrongly signed token, or a token for another room. | Get a fresh token from your server. |
e2ee_conflict | End-to-end encryption: the room has the mix, a recording, RTMP, the cloud player or moderation on. | Stop them, or join with e2ee.allowConflicts. |
e2ee_unsupported | E2EE asked on a runtime that cannot encrypt frames (a browser without encoded transforms, React Native without RTCFrameCryptorFactory, 'exchange' on React Native) or on a server without E2EE. | Use a browser with RTCRtpScriptTransform; on React Native pass RTCFrameCryptorFactory and a key / keyProvider; or join without E2EE. |
insufficient_credit | The account ran out of credit. Rooms in progress are closed. | Top up. Set a low-balance alert to see it coming. |
app_disabled | Project revoked or offline, or account suspended. | Check the project status in the console. |
room_full | The room reached the account's maximum size. | Ask us to raise the limit. |
replaced | The same userId joined again from another device or connection. | Expected on reconnect from a second device; show "connected elsewhere". |
kicked | Your server removed this person (/kick). | Leave the room screen. |
room_closed | Your server closed the room (/close). | Leave the room screen. |
banned | The moderation banned this user (you approved it, or auto_ban). Joins are refused until the ban ends. | Show the ban; lift it with DELETE /v1/moderation/bans/{userId}. |
viewer_cannot_publish (a viewer tried to publish) does not end the session. Non-terminal errors (network, internal) are reported too; with autoReconnect the SDK reconnects by itself and fires connectionStateChanged('reconnecting').
Limits
| Item | Limit |
|---|---|
roomId | [0-9A-Za-z_-], 1–80 characters |
userId | [0-9A-Za-z_-], 1–40 characters |
| Token lifetime | 60 s to 7 days (default 24 h) |
| People per room | 50 by default; raised per account |
| Viewers per room | 1 000 (viewer tokens, outside the people limit) |
| One connection per user | Joining again with the same userId replaces the previous connection |
| Keep-alive | The SDK pings every 20 s |
| Callback timeout | 5 s, no retries |
Measured load
Synthetic participants that speak this protocol (Go + Pion) against the full path — edge Worker, room coordinator, Cloudflare Realtime and TURN — on our development server, from one test site in São Paulo (2026-11-02). Hold 180 s unless noted.
| Run | People | Audio delivered | Join (median) | Join → first audio (median) |
|---|---|---|---|---|
| 10 rooms × 5 (2 speak, 3 listen) | 50 | 99.9% | 0.73 s | 1.72 s |
| 50 rooms × 5 (60 s hold) | 250 | 99.7% | 0.72 s | 1.76 s |
| 1 room × (4 speak + 50 listen) | 54 | 97.9% | 0.66 s | 1.56 s |
| 1 room × (2 speak + 200 listen), 4 joins/s | 202 | 97.7% | 0.65 s | 1.64 s |
| TURN: 1,000 credential requests, 100 at a time | — | all HTTP 200 in 1.0 s (p50 46 ms); 50/50 real allocations; no rate limit reached | ||
Churn of ~8 joins and 8 leaves per second in one room: no join refused. At 100 rooms × 5 the test site's own link saturated (about 25 Mbps of received audio), so that run measures the bench, not the service. Join latency is dominated by distance: the room coordinator runs in North America, so a participant in Brazil pays about two round trips of ~120 ms per signaling exchange — this is what we are working on.
Billing
ILORB Signal is prepaid. The account balance is kept in participant-minutes: each person connected to a room for one minute consumes one, whether they speak or listen. Rooms are metered continuously; when the balance reaches zero, open rooms close with insufficient_credit and new joins are refused until the next top-up.
- Console → Usage: any period, grouped by day, project or room, with CSV export.
- Console → Billing: balance, average per day, how long the balance lasts, monthly statements, the low-balance alert (also sent as the
balance.lowcallback) and the card top-up (Billing & payments). - GET /v1/balance: the same balance from your server.
WebSocket protocol
You don't need this if you use the SDK; it is documented for other platforms and for debugging. Connect to:
Messages are JSON with a type field t. Audio goes over WebRTC to the Cloudflare SFU (STUN stun:stun.cloudflare.com:3478); the socket carries signaling only. Your microphone always publishes in a session of its own, separate from the session that receives the others.
Client → server
| Message | Fields | Purpose |
|---|---|---|
join | name?, caps? | First message after connecting. caps: what this client supports, e.g. ['close-offer', 'keyframe', 'en'] (protocol 12: 'en' = speaks the English protocol values below). |
publish | sdp, mid, trackName | Offer to publish my microphone. Track name: {roomId}-{userId}. |
unpublish | mid | Stop publishing. |
subscribe | tracks: [{ userId, trackName }] | Receive these voices. |
answer | sdp | Answer to a renegotiate. |
unsubscribe | mids | Stop receiving these tracks. |
mic | open | Tell the room my microphone opened/closed. |
cam | open | Tell the room my camera opened/closed (protocol 3). |
broadcast | message | Text to the others. |
pos | x, y, z, team? | My position and team (null = none, absent = unchanged). Protocol 10. |
attrs | room?, me?, id? | Change room and/or my attributes (null deletes). Protocol 9. |
e2ee | data, to? | E2EE key exchange (protocol 15, cap 'e2ee'): an opaque message (public keys, wrapped room keys) relayed to to or to every other client with the cap. ≤ 2048 chars, ≤ 20/s. |
ping | — | Keep-alive (every 20 s). |
Server → client
| Message | Fields | Meaning |
|---|---|---|
e2ee | from, data | Protocol 15: a key-exchange message from from. A client that sent 'e2ee' in join.caps also gets welcome.e2ee.conflicts: what is on in the room that cannot see encrypted media (mix, recording, person-recording, rtmp, player, moderation). |
welcome | me, people[], caps | Joined; who is already there (with their tracks and mic state). caps: what the room supports; includes 'en' from protocol 12. |
published | mid, sdp | Answer to my publish. |
renegotiate | sdp, mids[] | Offer for the receiving session. mids maps each local mid to its owner; always use the latest map (the SFU reuses mids). |
user-joined / user-left | userId, name? | Presence. |
track-added / track-removed | userId, trackName | Someone went on/off stage. |
mic | userId, open | Someone's microphone opened/closed. |
cam | userId, open | Someone's camera opened/closed (protocol 3). |
broadcast | from, message | Text from someone. |
error | code, message | See error codes. |
pos | userId, x, y, z, team | Someone's position and team, at most 5×/s per person (the latest always arrives). Protocol 10. |
attrs | by, room?, userId?, member?, id? | What changed (null = deleted). id only to whoever asked; a refusal comes as error with the same id. Protocol 9. |
viewers | count | How many viewers are in the room (grouped, at most 1×/s). welcome also carries viewers (only when there are any) and role: "viewer" for a viewer. Protocol 17. |
receive-mode | mode | To a viewer only: receive the mix ('mix') or everyone ('individual'). Arrives before welcome and whenever the mix starts or stops. Protocol 17. |
pong | — | Reply to ping. |
Protocol versions
Each version only adds; older clients keep working (the room speaks the dialect each client announces). Production server: 14. Development server: 16 (15 and 16 reach production with the next server release).
| Version | Adds |
|---|---|
| 2 | The base protocol, in English: join, publish, subscribe, mic, broadcast, kick and close from your server. |
| 3 | Video: kind: 'video', camera track {roomId}-{userId}-video, cam messages. |
| 4 | Linked rooms (PK): linked / unlinked, roomId on people and tracks. |
| 5 | Simulcast: publish.layers, layer. |
| 6 | Diagnostics: stats every 30 s, republish. |
| 7 | Screen sharing: kind: 'screen', track {roomId}-{userId}-screen. |
| 8 | Cloud mix: { t: 'receive', mode: 'mix' }, the person __mix. |
| 9 | Room & member attributes: attrs. |
| 10 | Position and team: pos. |
| 11 | The music's own track: kind: 'music', cap 'music'. |
| 12 | English values: cap 'en'. |
| 13 | Stage seats: stage.seats / seat.<n> in the room attributes, enforced by the room. |
| 14 | Content moderation: cap 'moderation', moderation-config, moderation. |
| 15 | End-to-end encryption: cap 'e2ee', welcome.e2ee.conflicts, the e2ee relay. PREVIEW |
| 16 | PK battle: { t: 'pk' } ops and events, room attribute pk:state, welcome.now, cap 'pk'. PREVIEW |
Protocol 4 adds linked rooms: linked / unlinked messages and an optional roomId on people and track messages (absent = your room). Protocol 16 adds the PK battle: the client sends { t: 'pk', op: 'invite' | 'accept' | 'reject' | 'cancel' | 'end' | 'score' | 'tick', … }, the server answers with { t: 'pk', event: 'invited' | 'invite-sent' | 'accepted' | 'rejected' | 'cancelled' | 'expired' | 'failed', … }; the state is the room attribute pk:state; welcome carries now (the server clock) and the cap 'pk'.
Protocol version: 3 (adds video: publish/unpublish/published take kind: 'video', camera tracks are named {roomId}-{userId}-video, and cam messages). Clients on version 2 keep working.
Protocol 5 adds simulcast: publish takes layers (rids, largest first) and the client sends layer (trackName, 'high' | 'low'). Protocol 6 adds diagnostics: the client sends stats every 30 s, and the server may send republish (kind) when the media server lost a track. Protocol 7 adds screen sharing: publish / unpublish / published and republish take kind: 'screen', and the screen track is named {roomId}-{userId}-screen (no simulcast layers). Protocol 9 adds attributes: attrs both ways, welcome.roomAttributes and people[].attributes. Protocol 10 adds range voice: pos both ways and people[].position / team in welcome. Protocol 11 adds the music's own track: publish / republish take kind: 'music' (a separate publication that does not replace the microphone; the room announces it with the 'music' cap). Protocol 12 adds the 'en' cap in join.caps and welcome.caps: everything the client and the server exchange uses English values. Protocols 13–16: see the table above. Current version: 16 (development server; production: 14).
Stats values (protocol 12)
The stats message ({ t: 'stats', s }) carries the connection sample. Fields with fixed values:
| Field | Values |
|---|---|
s.micBy | 'self' (the user), 'owner' (the room owner) or 'auto' — who closed the microphone while it is muted; null when it is open. |
s.receiveRelay | 'building', 'waiting', 'fetching', 'no-credential', 'unsupported', 'failed-udp'. |
s.jitterTarget | 'applied', 'unavailable'. |
s.dev.net | 'wifi', 'cellular', 'wired', 'vpn', 'other'. |
s.platform | 'react-native-android', 'react-native-ios', 'web'. |
s.echo.canceller | 'aec3' (WebRTC), 'device' (Android hardware canceller), 'ios-voice-processing', 'off'. |
s.dev.thermal | Android thermal status: 0 none, 1 light, 2 moderate, 3 severe, 4 critical, 5 emergency, 6 shutdown. iOS maps s.dev.thermalState 'nominal' → 0, 'fair' → 1, 'serious' → 3, 'critical' → 4. |
Live blocks (2026-11-01; every block is sent on every platform, a field the platform cannot measure is null):
| Block | Fields | Not available |
|---|---|---|
s.echo | erl, erle (dB, now), erlWin, erleWin, samples, likelihood (0–1), couplingDb, corr, residual, canceller, autoStep | erl/erle on iOS (Apple's voice processing does not report them); likelihood/coupling in the browser (no native echo meter). |
s.voice | micDb, speechDb, speechP90Db, speechS, speechPct, seconds, agcDb, noise, noiseWanted, webrtcNs, webrtcAgc | agcDb and the running noise in the browser (no native chain); all levels while the microphone is muted. |
s.dev | net, netChanges, cpuPct, thermal, powerSave, memFreeMb, memLow, battery, charging; Android: wifiDbm, cellLevel, underruns, batteryOptIgnored; iOS: thermalState; browser: netEffective, downlinkMbps, cores, deviceMemoryGb | iOS: Wi-Fi dBm, cellular level, underruns, battery optimisation (no public API). Browser: CPU, thermal, free memory; network type and battery only in Chromium. |
s.video | cam { wanted, publishing, muted, pc }, out { width, height, fps, kbps, limitation }, in { n, fpsAvg, fpsMin, lossMax, kbps, heightMin, frozen }; s.videoLight, s.videoLightReason | out / in are null with no camera published / received. |
s.fx | fps, frameMs, detectMs, lagMs, delegate, width, height, effects | null without ILORB FX on the camera; detectMs/lagMs only with a face effect (lagMs Android only). |
s.rtc | out[] (each track I send: kind, rid, codec, kbps; video: w, h, fps, encoder, hw, limit, nack, pli, fir) and in[] (each track I receive, by user: codec, kbps, lossPct, jitterMs, jbMs; video: w, h, fps, decoder, hw, freezes, freezeS, dropped, nack, pli, fir; audio: concealPct, levelDb), inTotal, rxVoiceDbMax | Browser: encoder/decoder/hw are hidden by the browser from a page with no camera/microphone permission (a viewer that never asked). |
s.js, s.reconnectLog, s.appStateLog | JS event-loop lag in the window (lagAvgMs, lagMaxMs, ticks: a busy or CPU-starved JS thread shows here, network loss does not); every reconnection { at, reason } (UTC time; e.g. socket closed (1006 …), room silent for 4.9 s (half-open connection), internet came back); foreground/background transitions { at, state } (from setBackground) | — |
s.dev CPU | cores, cpuPct (app, % of ONE core — 160 on an 8-core phone is 20 % of it), cpuAppPct (app, % of the whole device), deviceCpuPct and coreLoadPct[] (whole device), cpuByGroup (app CPU by part: js, video, audio, rtc, fx, camera, ui, other; % of one core), cpuFreqMhz[], cpuFreqPct (clock now ÷ max: throttling), cpuMaxMhz, gpuPct; battery batteryTempC, batteryMv, plugged; network netDownKbps, netUpKbps, netMetered, wifiLinkMbps, wifiFreqMhz, cellDbm; iOS netConstrained, memFootprintMb | Android: deviceCpuPct/coreLoadPct only where /proc/stat is readable (closed on most phones since Android 8), gpuPct only where the vendor exposes it. iOS: no CPU frequency, GPU load, battery temperature/voltage, link bandwidth or signal. Browser: only cores. |
s.diag | source: 'sdk', summary, echo, echoAlert, profile, platform, nativeVersion | — |
App broadcast: fix echo
The room owner (or an admin) asks the server to fix a listener's echo with an app broadcast whose message is { "topic": "fix-echo", "text": "{\"to\":\"<userId>\",\"id\":…}" } (text is a JSON string). The server limits these requests and relays the broadcast to each recipient in the dialect it speaks: clients with the 'en' cap get fix-echo / to.
Changelog
| Date | Change |
|---|---|
| 2026-11-08 | Viewers (preview): token role: 'viewer' (server-auth and every server SDK) — viewers do not publish (viewer_cannot_publish, not terminal), are not listed (only the viewers counter, viewersChanged, getViewerCount), and go on the cloud mix by themselves while it is on (receive-mode, receiveModeChanged); 1 000 per room outside the plan limit; room state viewers / warnings; callbacks carry role. Protocol 17. |
| 2026-11-05 | Video on weak phones: with no camera effect (or only the mirror) the React Native Android camera now reaches the encoder at its own size and the mirror is the texture's transform (no copy) — before, every frame was scaled to the effects working size and a 540p preset went out at 270×480; the encoder layers follow the frame size the sender really receives. While the camera is live the platform keeps the screen on, asks sustained performance where supported and raises the video threads (platform.setLivePerformance, tuning livePerformance); a camera held by the bandwidth estimate no longer falls back from hardware H.264 to software VP8; front camera with face-priority exposure where the device offers it. IlorbDevice.openBatteryOptimizationSettings() opens the system list to exempt the app from battery optimisation. |
| 2026-11-05 | Video, camera on one layer when it must be: device caps with simulcast: false that arrive after the camera is live now switch the small layer off (before, it became a second full-size encode), and a return from the background no longer turns it back on; a camera that encodes no frame for 5 s with the camera delivering drops to one layer, then is published again (event videoCodecFallback from simulcast to single; tuning cameraStallMs). Device table: class medium publishes one layer, and the measured per-model caps skip sessions limited by bandwidth (rule bwLimitPctMax, 50%). |
| 2026-11-04 | Billing & payments: ILORB FX becomes a recurring licence (monthly or yearly with a discount, Stripe Billing, billing portal, monthly → yearly) with GET /v1/fx/license/status and POST /v1/fx/license/refresh; a failed renewal gives a grace period, then the server refuses the licence (402 subscription_suspended). New Live gifts: a viewer buys one gift for someone in the room (/v1/rooms/{roomId}/gifts), delivered once as a gift room message and the gift.purchased callback. A top-up that just landed opens a room at once. Development server, Stripe test mode. |
| 2026-11-04 | Video, fast picture on return: suspendCamera() / resumeCamera() for a host going to the background (the publication stays, a keyframe on return); setVideoReceiving(userId, false) holds the subscription 10 s in the foreground (tuning videoOffHoldMs), so leaving the live screen and coming back keeps the picture; the selected ICE pair is pinged every 500 ms (tuning iceStablePingMs, React Native Android with the SDK's react-native-webrtc patch), which removes the up to 2.5 s wait for the nomination on join and reconnect. Also: event videoWaiting / isVideoWaiting (last frame + loading sign instead of black), a keyframe asked again every 150 ms for 3 s after the background while a camera does not decode, and IlorbLiveService (Android foreground service for lives). |
| 2026-11-04 | React Native screen sharing (preview): Android captures through the package's own native module (waits for the mediaProjection foreground service, keeps the aspect ratio, maxSide / fps / entireScreen, rotation, Android 14 single-app, a Stop button in its notification, IlorbScreen.stats); the Expo plugin now creates the iOS extension target and adds background audio. startScreenShare option stopTrackOnEnd. Built, not yet tested on real phones. |
| 2026-11-03 | Video enhancement: super resolution (superRes, upscaleTo) on Android (GPU) and iOS. Server: a viewer joining a live gets the SFU session asked at join, one SFU call sooner. |
| 2026-11-03 | Chat push: POST /v1/server/push/unregister drops tokens FCM/APNs called dead; the sample server delivers chat.push with PushSender when push credentials are set (push). |
| 2026-11-03 | Noisy place warning (beta, Android): option noiseWarning, setNoiseWarning, getNoiseQuality, event noiseWarning, noiseWarningMessage (en/pt/es); native meter on the raw mic with server-tunable thresholds. React Native iOS written, untested on a device. |
| 2026-11-02 | Console: Stream keys (create and revoke OBS keys, the WHIP URL + bearer token, the RTMP server + key when an RTMP gateway is set up) and a read-only Karaoke catalogue with previews, under Develop. Development server. |
| 2026-11-02 | ILORB Live Studio (beta): the desktop/browser streaming app — scenes, overlays, alerts, mixer — that goes live straight into a room through the SDK. The SDK now retries the H.264 codec preference when a platform encoder lists a send-only profile (Electron 33 published VP8 before). |
| 2026-11-02 | Karaoke (preview, browser): IlorbKaraoke (synced lyrics, pitch scoring, chorus), KaraokeCatalogue, IlorbAudio.setVoiceMorph / voiceMorphStats; SDK hooks setMicSenderHook, getAudioReceiver; GET /v1/karaoke/songs. Development server. |
| 2026-11-03 | Spatial audio in the browser: the same pan law as the phones' C mixer (unity in the center), getSpatialStats() on the web (measured L/R output per voice), spatialPanGains(pan) exported. |
| 2026-11-02 | Speech detector and spatial audio (beta, Android): option vad, setVad, getVadStats, event localSpeakingChanged, send gate (up to 96% less data with an open, silent mic), music likeness; per-voice stereo pan on React Native Android and Kotlin, getSpatialStats. Swift and React Native iOS written, untested on a device. |
| 2026-11-02 | Native audio: the Android and iOS capture cores are now one shared DSP (bit-exact with the old code), and the AI noise filter has its own polyphase resampler (8, 24, 32, 44.1 kHz ↔ 48 kHz; fixes a buffer overrun at 32 kHz). Android 32-bit: the adaptive jitter buffer no longer crashes (SIGILL) on some phones. |
| 2026-11-02 | @ilorb/signal-web-fx (beta): IlorbAudio, IlorbMediaPlayer, IlorbMusicTrack, IlorbFx for the browser, with the React Native names. |
| 2026-11-02 | Desktop: @ilorb/signal-electron (beta), the .NET client and the Unity package (preview); the Swift SDK runs on macOS. Platforms table with each SDK's status. |
| 2026-11-02 | Server SDKs 0.2.0 (Python, Go, Java, PHP): callback eventId receiver, call push, mix, recording (room and per person), RTMP, cloud player, moderation and Live Shop; PHP tested for the first time; all four run against the development server. Not on public registries yet. |
| 2026-11-02 | ILORB FX makeup map: 59 makeup models approved on the effects demo and matched by the SDK; live on the GPU on Android (~1 ms per effect per frame); iOS placement and curves equal to Android. |
| 2026-11-02 | Measured load: 250 people in 50 rooms (99.7% of the audio delivered), 202 in one room, churn, 1,000 TURN credentials in 1 s. Stage seats documented (voice.stage, protocol 13) and the protocol versions table. |
| 2026-11-02 | Stream with OBS (preview): stream keys (/v1/ingest/keys), the WHIP endpoint for OBS 30+ (/v1/ingest/whip/{roomId}), RTMP through the ingest gateway, GET /v1/ingest/rooms/{roomId}; the streamer is the person __ingest:<userId>; SDK createStreamKey / revokeStreamKey / getIngestStatus / ingestChanged, Node IngestClient, token claim ingest; callbacks ingest.live / ingest.ended; room message { t: 'ingest' }. |
| 2026-11-02 | PK battle in the SDK (preview, development server): voice.pk — invite / accept / reject / cancel / end, sendGift, state, remainingMs, sides, events invited, inviteEnded, state, phase, score, ended, gift; POST /v1/rooms/{roomId}/pk (score, state, end); room attribute pk:state; protocol 16. |
| 2026-11-03 | End-to-end encryption on React Native (Android proven against a browser in the same room; iOS builds): the SDK runs WebRTC's native FrameCryptor when the app passes RTCFrameCryptorFactory in webrtc. The frame format and key schedule are now the FrameCryptor's for every platform (30 bytes per frame; browsers must update the SDK — the old format does not interoperate). getE2eeStats().engine. |
| 2026-11-02 | End-to-end encryption (beta, browser): option e2ee (key, keyProvider, 'exchange', allowConflicts), getE2eeStats, rotateE2eeKey, setE2eeKey, events e2eeDecryptionFailed / e2eeKeyChanged, errors e2ee_conflict / e2ee_unsupported; mix, recording, RTMP and the cloud player refuse to start in an encrypted room (409 e2ee_active); protocol 15 (cap 'e2ee', welcome.e2ee.conflicts, message e2ee). Development server. |
| 2026-11-01 | Live telemetry from every platform. The stats sample now carries echo, voice, dev, video, fx and diag on Android, iOS and the browser (iOS gains device stats — CPU, thermal state, memory, battery, network — and ILORB FX camera numbers; the iOS echo meter now runs without the residual suppressor). Console → Live shows them per person while the room runs. |
| 2026-11-01 | Callbacks: every callback carries a unique eventId (body and x-ilorb-event-id) to handle it once; new recording.segment_ready; CallbackReceiver / verifyCallback in @ilorb/signal-server/callback (signature, 300 s window, de-duplication, typed payloads); sample server examples/web receives them all. |
| 2026-11-01 | Per-person recording (beta): POST /v1/rooms/{roomId}/record { "scope": "person", "userIds"? } — each person's own voice and camera in their own files, listed with kind: "person" and userId; callbacks recording.person.started / recording.person.stopped; console Live Record each person. Mix layout grid | focus. Development server. |
| 2026-11-01 | Pre-call test: testCamera (frames, delivered fps and size, brightness, freezes) and runPrecheck (network, microphone, speaker and camera → one verdict, per-check details and advice codes); speakerTest; on Android the speaker test plays a real tone and reads the media volume. Demo app: examples/precall-demo. |
| 2026-11-01 | ILORB FX remote effects (beta, Android; development server): IlorbFx.catalog(), loadEffect, applyEffect, effectCache, clearEffectCache, setCatalogServer, FxEffectError; signed effect packages with hashed files; console Effect catalogue and editor; GET /v1/fx/catalog, /v1/fx/packages/{id}, /v1/fx/blobs/{sha256}. |
| 2026-11-01 | Live Shop (beta): Shopify and Nuvemshop catalogues per project (/v1/shop/*), the host pins products in the live (voice.shop.pin, /v1/rooms/{roomId}/shop/*), events pinnedProduct / productsChanged, openProduct returns the store's page or cart link and counts the tap. No payments: checkout on the store. Development server. |
| 2026-11-01 | Content moderation (beta): periodic camera frames and audio clips checked with your recognition provider key, your AI key, or your own server (webhook); video takedown, audio bans you approve; /v1/moderation/*; SDK events moderationAction, streamTakenDown, streamRestored; error banned; protocol 14 (moderation-config, moderation, cap 'moderation'). |
| 2026-11-01 | Video: device table — the SDK caps the account's preset by the phone (class by hardware, measured per model, operator rows; videoForPreset, videoCaps, event videoCapped); data synced to the frame (sendFrameData, setVideoElement, event frameData, option frameData; protocol message frame-data, cap 'frame-data'); ILORB FX video enhancement (IlorbFx.setEnhance). Development server. |
| 2026-11-01 | Chat preview: public channels for large audiences and message translation (development server). UIKit: ChannelList, ChannelThread, and media (photo, video, file, voice), typing, read ticks, translate, edit, recall and block in ChatThread. |
| 2026-10-29 | English protocol and API names. Protocol 12: 'en' in join.caps / welcome.caps; stats values in English (micBy: 'self' | 'owner' | 'auto', receiveRelay, jitterTarget, dev.net); app broadcast topic 'fix-echo' { to, id }. GET /v1/device-profile with en=1 returns English settings (settings.quality English keys, settings.tuning { sdk, native, app }, settings.sound), deviceClass (weak | medium | good) and lang: 'en'. |
| 2026-10-29 | Automatic device profile: VoiceClient fetches, caches and applies the device profile (quality, SDK and native tuning), polls in the foreground and sends CPU and echo with the session stats when @ilorb/signal-react-native-audio is imported; option deviceProfile, events deviceProfileChanged / needsRestart, class DeviceProfile. captureRecovery (reopenCapture, reopenCaptureIfNeeded). Android capture chain (AI noise, AGC, clarity, howling, voice gate, mic config) now ships in @ilorb/signal-react-native-audio (IlorbCapture, IlorbCaptureConfig.applyAtStartup). |
| 2026-10-28 | Device profiles: echo per device model, decided by the server from what the SDK measures on each phone (measure-only detector) — in calibration, observe mode. |
| 2026-10-28 | RTC API: native Android (ilorb-signal-rtc-android: RtcEngine, IRtcEngineEventHandler, Constants) and iOS (IlorbRtcKit: IlorbRtcEngineKit, IlorbRtcEngineDelegate, enums with the same raw values). VoiceClient.renewToken in Kotlin and Swift. |
| 2026-10-28 | RTC API: React Native (@ilorb/signal-rtc-react-native: engine, events, enums and RtcSurfaceView); RtcTokenBuilder.buildTokenWithUid for Node, Go, Java, PHP and Python; Web adds token renewal, network-quality, sendStreamMessage and data channels, live audience, dual stream. VoiceClient.renewToken. |
| 2026-10-27 | Device profiles v2: layered profile (default › device class › brand › model) and automatic device class from the hardware (RAM, cores, frequency, chip); about 188 SDK settings plus native audio settings tunable from the server, no new build. DTX and voice gate can be switched on from the server. |
| 2026-10-24 | RTC API: @ilorb/signal-rtc-web (IlorbRTC, client, tracks, events) over VoiceClient, voice and video. |
| 2026-10-23 | Chat: retention and group size per project (GET/PUT /v1/server/settings; default 90 days and 500 members; error group_full). |
| 2026-10-23 | In-app chat is live at chat-signal.ilorb.app (beta). |
| 2026-10-22 | Cloud player (beta): POST /v1/rooms/{roomId}/player (play, pause, resume, seek, stop) — a video or song from a URL (MP4, M4A, MP3, HLS) played in the room as __player; player in the room state; callbacks player.*. |
| 2026-10-22 | Range voice & teams (beta): setPosition, setAudioRange, setTeam, setRangeMode('off' | 'world' | 'team'), getRangeInfo, event positionChanged, RoomUser.position / team; volume by distance, stereo pan (browser), people out of range unsubscribed (with hysteresis). Protocol 10. |
| 2026-10-21 | Cloud recording (beta): POST /v1/rooms/{roomId}/record, GET …/recordings (with each chunk's duration), GET …/recordings/{id}/{file} (Range), DELETE …/recordings/{id}; callbacks recording.started / recording.stopped; retention per project (default 30 days); Console → Recordings (player) and Record / Stop in Live. |
| 2026-10-21 | Room & member attributes (beta): setRoomAttributes, deleteRoomAttributes, getRoomAttributes, setMyAttributes, events roomAttributesUpdated / memberAttributesUpdated, RoomUser.attributes; POST /v1/rooms/{roomId}/attrs with writers: 'admin'; createUserToken({ admin }). Protocol 9. |
| 2026-10-21 | Screen sharing (beta) in VoiceClient: startScreenShare / stopScreenShare, screenCapture and autoSubscribeScreen options, events remoteScreenStarted / remoteScreenStopped / screenShareEnded, getScreenStream, setScreenReceiving, RoomUser.screenOn, errors screen_share_*. Protocol 7. |
| 2026-10-21 | Server SDKs (beta) for Python, Go and Java (tested) and PHP (untested): user tokens identical to server-auth, callback verification, rooms and chat server APIs. |
| 2026-10-21 | Chat (preview): offline history and send queue (storage, cache, getCachedConversations, getCachedHistory, pendingMessages, events synced / pendingMessageSent / pendingMessageFailed); push with the app closed (setPushToken, removePushToken, setPushSettings, chat.push callback, POST /v1/server/push/test); subscription by MAU (Free 100 MAU, paid plans coming soon, GET /v1/server/mau, Console → Chat). |
| 2026-10-21 | UIKit for React Native (preview): ChatList, ChatThread, CallScreen, AudioRoom, LiveVideo, themes and texts. |
| 2026-10-21 | Console → Live: each person in an open room with device, version, microphone, network and the app's diag message, refreshed every 10 s, problems in red. |
| 2026-10-21 | Simulcast (beta): video.simulcast and setVideoQuality; getVideoStats and events remoteVideoFirstFrame, remoteVideoSizeChanged, localVideoFirstFrame; lowNetworkVideo: 'audio-only' with videoPausedForNetwork. Protocol 5. |
| 2026-10-21 | Pre-call test (beta): testNetwork, testMicrophone, testSpeaker. /v1/probe reaches production with the next server release. |
| 2026-10-21 | Diagnostics: device, sendStats, statsIntervalMs; stats every 30 s shown per device model in the console (Develop → Diagnostics); reconnectNow(); muteLeaks / republishes in getConnectionInfo(); automatic microphone republish when the media server loses the track. Protocol 6. |
| 2026-10-21 | Native audio (beta): residual echo (echoResidual, Android), setVoiceChanger, setReverb, in-ear monitoring (iOS), echoStats / voiceFxStats. |
| 2026-10-21 | Screen capture for React Native (preview): @ilorb/signal-react-native-screen (IlorbScreen.start / stop), used by startScreenShare through screenCapture. |
| 2026-10-21 | ILORB FX (beta): setFace (beauty, makeup, shape, image, background), stickers that follow expressions and animate, setParticles, setTextBalloon, setFacePaint, setAvatar + setAvatarVoiceLevel, processImage; package avatar. |
| 2026-10-21 | In-app chat (preview): @ilorb/signal-chat with 1:1, groups, media, reactions, receipts, search, presence, moderation and call invitations. Subscription by MAU, pricing coming soon. Not deployed yet. |
| 2026-10-20 | ILORB FX (beta): video effects engine with its own license bound to the app ID (OFX1, ECDSA P-256), issued in Project → ILORB FX; POST /v1/fx/verify. |
| 2026-10-20 | Engine API: setup guide and a call-by-call table for @ilorb/signal-engine (IlorbEngine). |
| 2026-10-20 | Engine API: @ilorb/signal-engine, IlorbEngine — a voice room engine (methods, events, enums and error codes) over VoiceClient. |
| 2026-10-20 | PK (beta): link two rooms (POST /v1/rooms/{id}/link, unlink), server messages (broadcast), events roomLinked / roomUnlinked, RoomUser.roomId, getLinkedRooms(). Protocol 4. |
| 2026-10-20 | Video (beta): startCamera, stopCamera, muteCamera, switchCamera, setVideoReceiving, events videoTrackChanged / localVideoChanged, RoomUser.camera / cameraOn. Protocol 3 (camera track {roomId}-{userId}-video, message cam), compatible with 2. getConnectionInfo() for diagnostics. |
| 2026-10-20 | balance.low callback and low-balance alert; Usage with CSV; monthly statements; team members (admin / read-only); browser test room; browser SDK build at /sdk/ilorb-signal.js; device profiles endpoint. |
| 2026-10-20 | Projects, signed callbacks (room.started, user.joined, user.left, room.ended), test-token tools. |
| 2026-10-20 | Protocol v2, all in English. Muted microphone sends zero packets; muteUser and setReceiving unsubscribe; isConnectionAlive / resume; server moderation (state, kick, close). |