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 audience to 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.

1

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.

Client
await engine.sendData(JSON.stringify({
type: 'app.raise-hand',
uid: engine.localUid,
}), true);
2

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.

server.js
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 now
role: '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.

3

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.

Client
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 themselves
camera: 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.

4

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.

SourceShapeUse it for
active-speakers-changedArray of identities, ordered by loudnessHighlighting rings, a "now speaking" label, sorting the stage.
user.isSpeakingBoolean on a remote userChecking 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 speaker
row.classList.toggle('muted', !user.audioEnabled);

Next#