Skip to content

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):

RoleOn stage (speaks)ModeratesTypical use
hostyesyesthe room's owner
cohostyesnoa speaker on stage
moderatornoyessomeone who keeps order
viewernonoa 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

CallEffect
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.
  • onUsageLimit and onWalletLow tell the host when the account behind the room is running out, before the room ends.
  • Someone whose connection drops shows as onParticipantLeft with reason disconnected, then onParticipantJoined when their SDK has reconnected; their seat stays away meanwhile. left means they closed the room on purpose.
  • When the room is over, endRoom() ends it for everyone (onRoomClosed, then onClosed(roomClosed)).

Related: Rooms, One-to-one voice calls, Troubleshooting.

Last updated . Something unclear or wrong? Tell us.