Build
Group audio rooms: speakers, listeners and the stage
Build voice chat rooms with VoltKit RTC: hosts and speakers on stage, listeners who raise a hand, seats, moderation, speaking indicators and chat.
An audio room is a room where a few people speak and everyone else listens: a voice chat room, a talk, a radio-style show. Speakers publish their microphone; listeners only receive. A listener raises a hand, the host brings them on stage, and they speak. The examples are Kotlin (Android SDK); the JavaScript and Flutter SDKs use the same names.
Roles
Your backend chooses each person's role when it mints their token (Issue tokens from your backend):
| Role | On stage (speaks) | Moderates | Typical use |
|---|---|---|---|
host | yes | yes | the room's owner |
cohost | yes | no | a speaker on stage |
moderator | no | yes | someone who keeps order |
viewer | no | no | a listener |
Listeners are cheap: they receive audio only. Roles change during the room without your backend: the host promotes a listener to cohost to bring them on stage and back to viewer to send them back. See Authentication.
Joining
val client = StreamClient(context, wsUrl, token)
scope.launch { client.participants.collect { roster -> renderRoom(roster) } } // kept current for you
scope.launch { client.onActiveSpeakers.collect { ids -> highlight(ids) } } // who is talking now
scope.launch { client.onTrackMuted.collect { m -> showMuted(m.identity, m.paused) } }
val join = client.connect(mapOf("name" to "Ravi", "avatar" to avatarUrl))
if (join.role.canPublish) client.publish(PublishOptions(video = false)) // hosts and speakers
participants is a StateFlow of everyone in the room, yourself included, with each person's role, metadata (what they joined with or set with setMetadata), handRaised and its handMetadata, and whether a moderator holds their microphone (muted) or camera (videoBlocked) off. It follows the room's events, so you never refetch it.
Raising a hand and the stage
// A listener asks to speak, saying for which seat:
client.raiseHand(mapOf("slot" to "seat-3"))
client.lowerHand() // changed their mind
// The host sees the requests:
scope.launch {
client.onHandRaised.collect { hand ->
if (hand.raised) showRequest(hand.identity, hand.slot) else removeRequest(hand.identity)
}
}
client.promote("user-42", Role.cohost) // accept: on stage
client.lowerHand("user-42") // decline (reason "declined")
client.promote("user-42", Role.viewer) // back to the audience
On the listener's side a promotion arrives as onRoleChanged for their own identity, and role reads cohost from then on, so publish() is allowed. When the room serves its listeners from another media server, a move to the speakers' server follows by itself (onMustReconnect with reason promoted, then onReconnected). A publish() made during the move waits for it; one the move cuts off throws disconnected, and the SDK publishes again once the move is done:
scope.launch {
client.onRoleChanged.collect { change ->
if (change.identity != client.identity) return@collect
if (change.role.canPublish) {
try {
client.publish(PublishOptions(video = false)) // brought on stage: speak
} catch (e: RpcError) {
if (e.code != "disconnected") showError(e.code) // disconnected: redone after the move
}
} else {
showListenerUi() // sent back to the audience
}
}
}
A speaker sent back to the audience stops publishing by itself: the microphone is released and everyone sees onTrackEnded.
Seats
Seats are named slots one person holds at a time, kept by the server:
client.claim("seat-3") // slot-taken, slot-blocked
client.release("seat-3")
client.claim("seat-5", identity = "user-42") // moderators may seat someone
client.blockSlot("seat-8") // moderators: lock an empty seat (slot-occupied while held)
scope.launch { client.onClaimChanged.collect { c -> drawSeat(c.key, c.identity, c.state) } } // held, away, free, blocked
A holder whose connection drops keeps the seat for a grace window: the state is away. Draw it as still taken, greyed. joinResult.claims and joinResult.blockedSlots are the seats as they are now.
Room-wide settings (a title, a seat layout, a pinned message) go in room metadata, persisted and sent to everyone who joins later:
client.setRoomMetadata(mapOf("title" to "Friday chill", "seats" to 8)) // moderators
scope.launch { client.onRoomMetadataChanged.collect { m -> applyRoomSettings(m.metadata) } }
Moderation
| Call | Effect |
|---|---|
forceMute(identity, "audio") | Their microphone stops at the server; they cannot switch it back on (force-muted) |
forceMute(identity, "audio", paused = false) | Released: they may switch it on again. It stays off until they do |
forceMute(identity, "video") / forceMute(identity) | The camera, or both |
kick(identity, reason) | Removed from the room; their token cannot rejoin |
muteChat(identity) | Silenced in the chat only |
endRoom() | The room ends for everyone (host only) |
The person muted gets onForceMuted, with localMedia.value.micForceMuted true: draw their microphone button locked. Everyone gets onParticipantMuted and onTrackMuted (the muted person too: onTrackMuted reports your own tracks), and the roster's muted flag. A released microphone stays off until its owner switches it on, so nobody is put on air without knowing.
Speaking indicators
scope.launch {
client.onAudioLevels.collect { levels -> // twice a second while collected
levels.forEach { seat(it.identity).ring(it.speaking) }
}
}
scope.launch { client.onActiveSpeakers.collect { ids -> spotlight(ids.firstOrNull()) } }
onAudioLevels covers every speaker you receive and your own microphone; onActiveSpeakers ranks who is talking, most active first, and fires only when the order changes.
Chat in the room
Every room has a chat on the same connection: sendMessage, getMessages (history, oldest first), deleteMessage, setTyping, and onMessage, onMessageDeleted, onChatMuted, onTyping. See Chat.
Music in the room
A host can put music the app plays on air with publishMusic, mixed into the microphone before encoding, so listeners hear it cleanly and it never echoes. See Music.
Large rooms
- Listeners receive audio only, which keeps rooms with many listeners light. Speakers on stage are what the room's audio is made of; a handful at once is typical.
onUsageLimitandonWalletLowtell the host when the account behind the room is running out, before the room ends.- Someone whose connection drops shows as
onParticipantLeftwithreasondisconnected, thenonParticipantJoinedwhen their SDK has reconnected; their seat staysawaymeanwhile.leftmeans they closed the room on purpose. - When the room is over,
endRoom()ends it for everyone (onRoomClosed, thenonClosed(roomClosed)).
Related: Rooms, One-to-one voice calls, Troubleshooting.
Last updated . Something unclear or wrong? Tell us.