Skip to content

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

  1. The caller taps Call. Your app asks your backend to start a call with the callee.
  2. 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.
  3. Both apps connect a StreamClient with their token and publish their microphone. Each hears the other as soon as both are in.
  4. Either side hangs up with close(). The other side sees onParticipantLeft with reason left. To end the call for both at once, the host calls endRoom() or your backend calls DELETE /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_AUDIO before publish(). Without it, publish() throws media-unavailable with data["permission"].
  • onTrackMuted tells you when the other side mutes; show it on their tile. It reports your own microphone too, hence the identity check.
  • onParticipantLeft says why: left when the other side hung up, disconnected when their connection dropped. Their SDK then reconnects by itself and they come back with onParticipantJoined, usually within seconds, so wait before ending the call.
  • close() is the hang-up. It releases the microphone and the audio routing, and fires onClosed(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 Telecom ConnectionService for 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 (onMediaReconnecting true, then false).
  • The connection drops: the SDK reconnects with backoff and republishes your microphone; connectionState is reconnecting meanwhile. Show "reconnecting…" rather than ending the call.
  • The other side's connection drops: you see onParticipantLeft with reason disconnected, then onParticipantJoined when they are back.
  • A signal-bars icon: onNetworkQuality every 3 seconds (excellent, good, poor, bad).
  • A speaking ring: onAudioLevels twice a second; AudioLevel.speaking for each side.
  • Your microphone stopped working (another app took it, a hardware mute switch): onMediaWarning with mic-silent or mic-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 seeWhat it means
connect() throws bad-tokenThe token expired before use or is for another room. Mint it right before joining.
connect() throws no-room or room-endedThe 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.
onUsageLimitThe 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.