Build
One-to-one voice calls
Build one-to-one voice calls with VoltKit RTC: a room per call, tokens from your backend, ringing, earpiece and speaker, mute, background and hang-up.
A one-to-one call is a room with two people in it, each publishing their microphone. This guide builds one end to end on Android; the JavaScript and Flutter SDKs have the same calls under the same names.
How a call flows
- The caller taps Call. Your app asks your backend to start a call with the callee.
- Your backend creates a room for the call (
PUT /v1/rooms/{callId}), mints a token for the caller (host) and one for the callee (cohost), and rings the callee through your own push channel (FCM) with the call id. See Issue tokens from your backend. - Both apps connect a
StreamClientwith their token and publish their microphone. Each hears the other as soon as both are in. - Either side hangs up with
close(). The other side seesonParticipantLeftwithreasonleft. To end the call for both at once, the host callsendRoom()or your backend callsDELETE /v1/rooms/{callId}.
A room costs nothing until someone joins, and usage is per participant-minute: a 10-minute call between two people is 20 participant-minutes of standard audio. See Usage and billing.
The call screen
class CallViewModel(app: Application) : AndroidViewModel(app) {
private var client: StreamClient? = null
private var peerAway: Job? = null
val status = MutableStateFlow("connecting")
val peerMuted = MutableStateFlow(false)
fun start(wsUrl: String, token: String, speakerphone: Boolean) {
val c = StreamClient(getApplication(), wsUrl, token, StreamOptions(speakerphone = speakerphone))
client = c
// Collect before connect(): the other side may already be there.
viewModelScope.launch {
c.onTrackMuted.collect { m -> if (m.identity != c.identity && m.kind == "audio") peerMuted.value = m.paused }
}
viewModelScope.launch {
c.onParticipantLeft.collect { gone ->
if (gone.reason == "left") {
hangUp() // they hung up
} else {
status.value = "reconnecting…" // their connection dropped: give it time
peerAway = viewModelScope.launch { delay(30_000); hangUp() }
}
}
}
viewModelScope.launch { c.onParticipantJoined.collect { peerAway?.cancel(); status.value = "connected" } }
viewModelScope.launch {
c.onConnectionStateChanged.collect { s ->
status.value = when (s.state) {
ConnectionState.reconnecting -> "reconnecting…"
ConnectionState.connected -> "connected"
else -> status.value
}
}
}
viewModelScope.launch { c.onClosed.collect { reason -> status.value = "ended ($reason)" } }
viewModelScope.launch {
try {
c.connect(mapOf("name" to "Asha"))
c.publish(PublishOptions(video = false)) // the microphone only
} catch (e: RpcError) {
status.value = "failed: ${e.code}"
c.close()
}
}
}
fun setMuted(muted: Boolean) = viewModelScope.launch { client?.setMicEnabled(!muted) }
fun setSpeaker(on: Boolean) = client?.setSpeakerphoneOn(on)
fun hangUp() { client?.close() }
override fun onCleared() { client?.close() }
}
- Remote audio plays by itself. There is nothing to subscribe to and nothing to render.
- Ask for
RECORD_AUDIObeforepublish(). Without it,publish()throwsmedia-unavailablewithdata["permission"]. onTrackMutedtells you when the other side mutes; show it on their tile. It reports your own microphone too, hence theidentitycheck.onParticipantLeftsays why:leftwhen the other side hung up,disconnectedwhen their connection dropped. Their SDK then reconnects by itself and they come back withonParticipantJoined, usually within seconds, so wait before ending the call.close()is the hang-up. It releases the microphone and the audio routing, and firesonClosed(left).
Earpiece, speaker and headsets
The SDK runs the call audio while connected: the call audio mode, audio focus, the voice-call volume, and the route. A connected Bluetooth headset or car kit wins, then a wired or USB headset, then the loudspeaker or the earpiece as you choose.
StreamOptions(speakerphone = false) // start on the earpiece, as a phone call does
client.setSpeakerphoneOn(true) // the speaker button
scope.launch { client.audioOutput.collect { out -> speakerIcon(out?.type) } } // speaker, earpiece, wiredHeadset, bluetooth, usb
val routes = client.listDevices(DeviceKind.audiooutput) // for an audio-route picker
client.switchDevice(DeviceKind.audiooutput, "speaker") // a choice by hand wins over a headset
Set volumeControlStream = AudioManager.STREAM_VOICE_CALL in the call Activity so the volume keys change the call volume. Headsets plugged in or connected during the call are followed (onDevicesChanged).
Ringing and incoming calls
The SDK does not ring: your push channel does. A common shape:
- Your backend sends the callee a high-priority FCM data message with the call id and the caller's name.
- The callee's app shows a full-screen incoming-call notification (
NotificationCompat.CallStyle.forIncomingCall), or uses Android's TelecomConnectionServicefor system call integration. - On Accept, the app asks your backend for its token and connects. On Decline, it tells your backend, which can end the room.
- Give up ringing after 30 to 60 seconds; your backend ends the room so the caller's app sees
onRoomClosed.
Fetch the token at accept time, not when the push arrives: tokens are short-lived (ten minutes by default).
The screen off and the app in the background
Android allows the microphone in the background only from a foreground service of type microphone, started while your app was visible. Start it when the call connects and stop it when the call ends; the Android SDK page has the service and the manifest entries. Without it the other side stops hearing you when the screen turns off.
Network changes and quality
- Wi-Fi to mobile data: the media path is repaired in about a second without dropping the call (
onMediaReconnectingtrue, then false). - The connection drops: the SDK reconnects with backoff and republishes your microphone;
connectionStateisreconnectingmeanwhile. Show "reconnecting…" rather than ending the call. - The other side's connection drops: you see
onParticipantLeftwithreasondisconnected, thenonParticipantJoinedwhen they are back. - A signal-bars icon:
onNetworkQualityevery 3 seconds (excellent,good,poor,bad). - A speaking ring:
onAudioLevelstwice a second;AudioLevel.speakingfor each side. - Your microphone stopped working (another app took it, a hardware mute switch):
onMediaWarningwithmic-silentormic-interrupted.
Standard audio (mono Opus up to 64 kbps, with in-band FEC) is the default and right for voice. HD audio (up to 128 kbps) is for music and is billed as HD.
Before the call: a quick check
val report = StreamClient.preflight(context, wsUrl, testToken) // a token for a test room
if (!report.ok) showNetworkWarning(report) // report.protocol, rttMs, lossPct, warnings
Mint testToken for a room of its own, never use the call's token: the same identity joining anywhere replaces its live session, which then ends with onClosed(replaced).
When something goes wrong
| What you see | What it means |
|---|---|
connect() throws bad-token | The token expired before use or is for another room. Mint it right before joining. |
connect() throws no-room or room-ended | The call ended before this side joined. |
onClosed(replaced) | The same identity joined from another device (or a pre-call check ran with the call's token). |
connect() throws connect-failed, "CLEARTEXT communication ... not permitted" | A ws:// URL. Production URLs are wss://; for a test server on your own network, allow cleartext in a debug-only network security config. |
onClosed(gaveUp) | The network did not come back after eight tries. Offer to call back. |
onUsageLimit | The account behind the call is running out; the call ends at endsAt unless topped up. |
More in Troubleshooting and Errors.
Last updated . Something unclear or wrong? Tell us.