Audio rooms
An audio room is a channel with no video: a handful of speakers publish audio, and everyone else listens. The pattern scales far better than a video call because listeners never publish anything.
Keeping it audio-only#
There is no separate audio-room mode. You get an audio room by never enabling the camera. Join with camera: false — which is the default — and do not call enableCamera anywhere in your UI.
import AppEngine from '@applooma/rtc-web';const engine = AppEngine.create({ appId: 'YOUR_APP_ID' });// Audio tracks still need to be attached, or nothing is heard.engine.on('track-subscribed', (track, user) => {if (track.kind !== 'audio') return;const el = track.attach();el.id = `audio-${user.uid}`;document.body.append(el); // audio elements can stay hidden});engine.on('track-unsubscribed', (track) => track.detach());const { token, wsUrl } = await fetchRoomToken('morning-standup');await engine.joinChannel(token, wsUrl, {role: 'host',microphone: true,camera: false, // default; never turn it on in an audio room});
Listeners are not prompted for the microphone
When the role is audience the SDK skips microphone and camera setup entirely. Listeners join without a device permission dialog, which is why joining an audio room as a listener is effectively instant.
Large listener counts#
Cost and capacity in a channel are driven by publishers, not by subscribers. Ten speakers and two thousand listeners is a much lighter channel than fifty people all publishing.
- Issue
audienceto everyone who is not currently on stage. It is the difference between a room that scales and one that does not. - Keep the speaker count small — under about a dozen — and promote people in and out rather than letting everyone publish.
- Do not render one DOM element per listener. Render the speakers, and show listeners as a count or a paged list.
- Listeners can still use data messages, so hand-raise, reactions and chat all work without publishing audio.
Reading the roster from your server
Client SDKs give you the participants your client is connected to. For a server-side view — a lobby screen, a moderation panel — use the room endpoints.
GET /v1/rooms — active channels for your project.
GET /v1/rooms/:roomName/participants
curl https://api.applooma.dev/v1/rooms \-H "x-applooma-key: ak_your_key" \-H "x-applooma-secret: as_your_secret"# → { "rooms": [ { "roomName": "morning-standup",# "participantCount": 412,# "createdAt": "2026-01-01T09:00:00.000Z",# "maxParticipants": 0 } ] }curl https://api.applooma.dev/v1/rooms/morning-standup/participants \-H "x-applooma-key: ak_your_key" \-H "x-applooma-secret: as_your_secret"# → { "participants": [ { "identity": "user-42",# "name": "Rina",# "joinedAt": "2026-01-01T09:03:11.000Z" } ] }
Promoting a listener to speaker#
Your server promotes a listener in place with POST /v1/rooms/:roomName/participants/:identity/role and a body of { "role": "cohost" }: they stay connected and gain the right to publish, with no rejoin. See changing a role in place. The steps below show the other way, a new token and a rejoin, which also works.
The listener asks to speak
Raise-hand is your own signalling. Send it over the data channel or over your normal backend API — either is fine, but the decision must end up on your server.
await engine.sendData(JSON.stringify({type: 'app.raise-hand',uid: engine.localUid,}), true);
Your server approves and issues a new token
The moderator's approval hits your backend. Record that this user is now a speaker, then mint a token with role cohost for the same channel and the same identity.
app.post('/api/rooms/:channel/promote', async (req, res) => {const moderator = await authenticate(req);const room = await db.rooms.find(req.params.channel);if (room.hostUserId !== moderator.id) return res.status(403).end();// Persist it: the next token request for this user must also say cohost.await db.rooms.addSpeaker(room.id, req.body.uid);const { token, wsUrl } = await applooma.createToken({roomName: req.params.channel,identity: req.body.uid, // the SAME identity they are using nowrole: 'cohost',metadata: { name: req.body.name },});res.json({ token, wsUrl }); // deliver to that user only});
Use cohost rather than host unless the person should also be able to moderate the channel.
The client rejoins with the new token
Leave the channel and join again with the upgraded token. The gap is a second or two; tell the user what is happening rather than letting the room appear to drop.
async function becomeSpeaker(newToken, wsUrl) {setStatus('Joining the stage…');await engine.leaveChannel();await engine.joinChannel(newToken, wsUrl, {role: 'cohost',microphone: false, // join muted; let them press unmute themselvescamera: false,});setStatus('You are on stage');}
Keep the identity the same
Rejoining with the same identity keeps gift totals, chat history and your own records pointing at one person. Changing identity on promotion makes them look like a different user.
Demoting is the same trip in reverse
Issue a token with role audience and rejoin. Since the new token has no publish permission, the microphone stops as soon as the client reconnects.
Do not keep a spare host token on the client
Pre-issuing a cohosttoken “just in case” and holding it in the client defeats the whole mechanism — the user can use it whenever they like, for six hours. Fetch the upgraded token at the moment of promotion.
Active-speaker detection#
The engine reports who is currently speaking, so you can highlight avatars without writing any audio analysis. There are two ways to read it.
| Source | Shape | Use it for |
|---|---|---|
active-speakers-changed | Array of identities, ordered by loudness | Highlighting rings, a "now speaking" label, sorting the stage. |
user.isSpeaking | Boolean on a remote user | Checking one specific participant while rendering their tile. |
engine.on('active-speakers-changed', (identities) => {// identities is the full current set — not a delta.document.querySelectorAll('.speaker').forEach((el) => {el.classList.toggle('is-speaking', identities.includes(el.dataset.uid));});// The loudest speaker first, if you want a single focus.setNowSpeaking(identities[0] ?? null);});
The event is a full set, not a diff
Every delivery contains everyone currently speaking. Replace your state with it rather than merging. It fires often while people talk, so keep the handler cheap — toggle a class, do not re-render the whole room.
Mute state
A speaker who mutes stops publishing audio, and remote users report audioEnabled === false. Draw the muted icon from that rather than from your own local guess, so it stays correct after a reconnect.
await engine.enableMicrophone(false); // mute yourself// Remote mute state, when rendering a speakerrow.classList.toggle('muted', !user.audioEnabled);