Build multi-modal AI applications using our new open-source Vision AI SDK.

React Native Audio Room Tutorial

Build a Clubhouse or Twitter Spaces style audio room for iOS and Android with Stream's React Native Video SDK. Hosts go live from backstage, listeners raise a hand to speak, and rooms scale to an unlimited audience.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your React Native audio room.

This tutorial builds an audio room like Twitter Spaces or Clubhouse. You'll end up with the app below, and along the way you get:

  • Backstage mode - start the room with your co-hosts and talk before going live.
  • Global edge network - calls run close to your users for low latency and scale.
  • No listener limit - rooms hold an unlimited audience.
  • Raise to speak - listeners request the mic; hosts grant or deny it.
  • Reliable audio - Opus RED and DTX keep audio intelligible on lossy connections.

Preview of the final result

This page works for both humans and AI coding agents: every step is a file operation or a shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Before you start. Emulators have poor audio support, so use a physical device; the first iOS device build also needs a signing team. You won't need a second device - from Step 6 onward you can join the room from a browser tab.

Choose your path

This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with the same working audio room, so pick the one you prefer instead of working through both.

Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the React Native integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.

Terminal (bash)
1
2
3
4
5
6
7
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder). The React Native pack # installs on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-react-native

Then ask your agent:

Prompt (markdown)
1
2
/stream-react-native Build a React Native audio room app with a participant list, a go-live toggle, and raise-to-speak permission requests.

Where you come in. The agent handles credentials and code. It stops three times: getstream init opening your browser to sign in and pick an app (new accounts get an organization in that flow), the build to a device, and the microphone prompt.

Want to get the sign-in out of the way first? Run getstream init before you prompt the agent - it picks up the initialized project from there.

Human checkpoint: build the app to a device if the agent hasn't, then allow microphone access. You should join the room and see yourself in the participant list.

Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.

Path B - Build it manually

Choose this path if you'd rather write the code yourself, or you want to understand what the SDK is doing before you hand it to an agent. Follow the steps below.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the React Native Video SDK is at https://getstream.io/video/docs/react-native/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Four pieces, one mental model:

  • StreamVideoClient - the low-level client. Holds the API key, the user, the token and the websocket connection to the Stream Video service.
  • Call - one room, created with client.call("audio_room", id). call.join() sets up audio; call.goLive() opens the room to listeners.
  • Providers - <StreamVideo client={client} /> puts the client in React context, <StreamCall call={call} /> puts one call in context. Everything below them reads from those.
  • Call state hooks - useCallStateHooks() hands you the hooks (useParticipants, useIsCallLive, useMicrophoneState, and the rest) that drive the UI.

Backstage makes audio rooms work. An audio_room call starts in backstage, where only the host and designated speakers can join; call.goLive() opens it to everyone. That's what lets co-hosts set up before an audience arrives. Disable it or retune its permissions per call type in the dashboard.

You'll build a room that joins and goes live first, then add the participant list, the controls, and raise-to-speak on top.

Step 1 - Create a new React Native app

First, set up your React Native environment. Then generate a project called "AudioRoomExample" with the React Native Community CLI:

Terminal (bash)
1
2
npx @react-native-community/cli@latest init AudioRoomExample cd AudioRoomExample

If you are having trouble with iOS, try to reinstall the dependencies by running:

  1. cd ios to navigate to the ios folder
  2. bundle install to install Bundler
  3. bundle exec pod install to install the iOS dependencies managed by CocoaPods

Checkpoint: you have an AudioRoomExample directory and yarn ios or yarn android builds the React Native starter screen.

Step 2 - Install the SDK and declare permissions

In order to install the Stream Video React Native SDK, run the following command in your terminal of choice:

Terminal (bash)
1
yarn add @stream-io/video-react-native-sdk @stream-io/react-native-webrtc

The SDK requires installing some peer dependencies. We can run the following command to install them:

Terminal (bash)
1
2
3
4
5
yarn add react-native-svg yarn add @react-native-community/netinfo # Install pods for iOS npx pod-install

Declare permissions

Android
iOS

In AndroidManifest.xml add the following permissions before the application section.

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-feature android:name="android.hardware.audio.output" /> <uses-feature android:name="android.hardware.microphone" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.INTERNET" /> ... <application ...> ... </application> </manifest>

Android Specific installation

In android/build.gradle add the following inside the buildscript section:

java
1
2
3
4
5
6
7
buildscript { ext { ... minSdkVersion = 24 } ... }

Run the app

Emulators have limited audio device support, so run on a physical device (how to). The first iOS device build needs a signing team: in Xcode, under Signing & Capabilities, select your Team.

To build to an emulator anyway:

bash
1
2
3
4
5
# run iOS app yarn ios # run Android app yarn android

Human checkpoint: the app builds and launches. Agents: run the build, then ask the human to confirm it launched.

Step 3 - Get Your Credentials

The code in Step 4 needs four values:

  • apiKey identifies your Stream Video application to our servers.
  • userId and a user object, for example { id: "john_smith", name: "John Smith" }.
  • token authorizes that user. Your server-side API mints it on sign-in; the Client & Authentication guide covers the production shape.
  • callId is anything URL-safe. Rooms are created the first time somebody joins with create: true.

There are two ways to get them.

Option 1 - Your own Stream app, via the Stream CLI

The getstream CLI provisions all of it in one flow. Run these from the AudioRoomExample directory you created in Step 1.

1. Install the CLI (skip if you did this in Path A):

Terminal (bash)
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.

Terminal (bash)
1
getstream init

Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish signing in before continuing. It's required first - token fails with "project credentials missing; run getstream init first" until it runs.

3. Mint a user token. The user you sign in as must be able to join backstage, so mint the token for the same id you'll add as the host member in Step 6:

Terminal (bash)
1
2
getstream token john_smith getstream token john_smith --ttl 1d

The CLI prints your API key alongside the token. Paste both into the constants in Step 4. (getstream env --target ios and --target android can write the key into the native projects instead, but the snippets here read a plain JavaScript constant, so pasting it is the shortest path.)

Checkpoint: you have an API key, a token, the user id you minted it for, and a call id you chose. All four belong to the same app.

Option 2 - Pre-filled tutorial credentials, no account

Want to skip account setup entirely? The App.tsx block in Step 4 is marked with the lock icon and filled in for you with working credentials against Stream's shared tutorial environment. Copy it as-is and it runs.

Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.

How the client and call fit together

client, an instance of the StreamVideoClient class, is the low-level client the SDK uses to talk to Stream. It connects users, queries calls, and creates them.

call, an instance of the Call class, performs call-specific actions: joining, muting participants, going live, leaving.

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// create client instance const client = new StreamVideoClient({ apiKey, user, token }); // Alternatively you can also choose to separate client creation and user connection: // const client = new StreamVideoClient({ apiKey }); // await client.connectUser(user, token); // create a call instance const call = client.call("audio_room", callId); call.join({ create: true, // create the call if it doesn't exist data: { members: [{ user_id: "mark" }, { user_id: "sara", role: "speaker" }], custom: { // custom data set on call title: "React Native test", description: "Conducting a test of React Native Audio Rooms", }, }, }); // Host makes the call live call.goLive();

Both instances reach the SDK's components through the two providers. StreamVideo wraps your component tree and takes the client; StreamCall wraps your call UI and takes the call. They're context providers, so every SDK hook below them works without prop drilling.

tsx
1
2
3
4
5
6
7
8
9
10
11
import { StreamCall, StreamVideo } from "@stream-io/video-react-native-sdk"; // ... <StreamVideo client={client}> <StreamCall call={call}> <View> <Text>My Video Call UI</Text> </View> </StreamCall> </StreamVideo>;

Step 4 - Setup Starter UI

Start with a basic UI for the audio room. A real app would use a navigation library like React Navigation; this tutorial mocks navigation with an activeScreen state variable to stay focused on the SDK.

Create a folder named src and add these files to it:

  • src/HomeScreen.tsx
  • src/CallScreen.tsx
  • src/AudioRoomUI.tsx

Now copy the following content into the respective files (as mentioned in header):

App.tsx
src/AudioRoomUI.tsx
src/CallScreen.tsx
src/HomeScreen.tsx
App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React, { useState } from "react"; import { SafeAreaView, StyleSheet } from "react-native"; import { HomeScreen } from "./src/HomeScreen"; import { CallScreen } from "./src/CallScreen"; const apiKey = "REPLACE_WITH_API_KEY"; const token = "REPLACE_WITH_TOKEN"; const userId = "REPLACE_WITH_USER_ID"; const callId = "REPLACE_WITH_CALL_ID"; export default function App() { const [activeScreen, setActiveScreen] = useState("home"); const goToCallScreen = () => setActiveScreen("call-screen"); const goToHomeScreen = () => setActiveScreen("home"); return ( <SafeAreaView style={styles.container}> {activeScreen === "call-screen" ? ( <CallScreen goToHomeScreen={goToHomeScreen} callId={callId} /> ) : ( <HomeScreen goToCallScreen={goToCallScreen} /> )} </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", textAlign: "center", }, });

Fill in apiKey, userId, token and callId from Step 3. They're hard-coded to keep the tutorial short; in production your backend returns the token at sign-in and the API key comes from environment config.

You should see the following UI on your device.

Preview of the Home Screen Preview of the Home Screen

Preview of empty audio room Preview of empty audio room

Checkpoint: the app shows the home screen with a "Join Audio Room" button, and tapping it switches to the placeholder room screen.

Step 5 - Setup Video Client

Now create the StreamVideoClient and connect the user. In a real app, create the client inside a useEffect and call client.disconnectUser() on unmount, so you don't open multiple websockets.

Pass the client to StreamVideo, which shares it with every child component. It goes at the top of the component tree.

App.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ... other imports import { StreamVideo, StreamVideoClient, } from "@stream-io/video-react-native-sdk"; // ... other code (api key, token, call_id, etc) const user = { id: userId, name: "John Malkovich", image: `https://getstream.io/random_png/?id=${userId}&name=John+Malkovich`, }; const client = new StreamVideoClient({ apiKey, user, token }); export default function App() { // ... other code (activeScreen, goToCallScreen, goToHomeScreen) return ( <StreamVideo client={client}> <SafeAreaView style={styles.container}>...</SafeAreaView> </StreamVideo> ); }

The UI won't change yet, because you haven't joined the call.

To re-run the app:

bash
1
2
3
4
yarn start # For Android type 'a' # For iOS type 'i'

Checkpoint: the app still builds and runs.

Step 6 - Create & Join a call

Now create and join the room. The call goes into a state variable and gets passed to StreamCall, which provides the hooks that drive the room UI.

Open src/CallScreen.tsx and replace it with this code:

src/CallScreen.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ... other imports import { StyleSheet, View, Text } from "react-native"; import { Call, StreamCall } from "@stream-io/video-react-native-sdk"; // ... props export const CallScreen = ({ goToHomeScreen, callId }: Props) => { const [call, setCall] = React.useState<Call>(); if (!call) { return <Text>Joining call...</Text>; } return ( <StreamCall call={call}> <View style={styles.container}> <AudioRoomUI goToHomeScreen={goToHomeScreen} /> </View> </StreamCall> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", textAlign: "center", }, });

As covered in Important Building Blocks, a call is created or accessed with client.call(...), so CallScreen needs the client. The useStreamVideoClient hook returns it.

Put the joining logic in a useEffect so the room is joined automatically when the user lands on CallScreen.

src/CallScreen.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { useEffect } from "react"; import { CallingState, useStreamVideoClient, } from "@stream-io/video-react-native-sdk"; // ... props export const CallScreen = ({ goToHomeScreen, callId }: Props) => { const [call, setCall] = React.useState<Call>(); const [joinError, setJoinError] = React.useState<string>(); const client = useStreamVideoClient(); useEffect(() => { if (!client) return; const myCall = client.call("audio_room", callId); myCall .join({ create: true, data: { members: [ { user_id: "john_smith", role: "host" }, { user_id: "jane_doe" }, ], custom: { title: "React Native test", description: "We are doing a test of react native audio rooms", }, }, }) .then(() => myCall.goLive()) .then(() => setCall(myCall)) // Without this, a failed join leaves the screen on "Joining call..." // forever - and in a release build you see no error at all. .catch((error) => { console.error("Failed to join the call", error); setJoinError(String(error)); }); return () => { if (myCall.state.callingState !== CallingState.LEFT) { myCall.leave(); } }; }, [client, callId]); // ... rest of the code };

Render joinError wherever you currently show "Joining call...", so a failure is visible instead of silent:

src/CallScreen.tsx (tsx)
1
2
3
4
5
6
if (joinError) { return <Text>Could not join: {joinError}</Text>; } if (!call) { return <Text>Joining call...</Text>; }

Important: who is allowed to join while backstage. An audio_room call starts in backstage, and only the call creator and members with the host or admin role can join before it goes live. Whoever runs this code creates the call, so they get in - the role: 'host' member above matters only for that other user, once they exist in your app. Anyone else joining a backstage room they didn't create fails with a JoinBackstage permission error, which is what the .catch() above surfaces. To let ordinary users in, call goLive() first, grant them admin from your server, or disable backstage for the call type in the dashboard.

Note the difference between the two calls. call.join() gets you into the room, which starts in backstage - only hosts and speakers can be there. call.goLive() opens it to everyone else. For now the code calls goLive() right after joining so the room is testable; Step 7 moves that onto a button.

You don't need a second device. Join the same room from your browser:

  1. Refresh the app and tap "Join Audio Room".
  2. Click "Join Call" below, then hit "Join" in the browser.
For testing you can join the call on our web-app:

You can't speak yet - that's the UI you build in Step 7 - but the browser shows the current user in its participant list.

Checkpoint: the browser participant list shows your mobile user.

Step 7 - Configure UI for Audio Room

The room joins and goes live. Now build its UI: a participant list with speaking indicators, and controls for live mode and the microphone.

Everything you need comes from useCallStateHooks(), which returns the individual state hooks. Each one re-renders your component when that piece of state changes:

tsx
1
2
3
4
5
6
const { useCallCustomData, useParticipants } = useCallStateHooks(); // Custom data is the `data.custom` property you set in `client.call()` method const custom = useCallCustomData(); const participants = useParticipants(); // .. and many more

The Call and Participant State guide lists all of them.

Scaffold three components inside AudioRoomUI, then fill them in one at a time:

  • AudioRoomDescription - the room's title and participant count.
  • AudioRoomParticipants - the participant list with speaking status.
  • AudioRoomControlsPanel - the live-mode and microphone controls.

Create the files:

AudioRoomDescription
AudioRoomParticipants
AudioRoomControlsPanel
src/AudioRoomDescription.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from "react"; import { StyleSheet, Text, View } from "react-native"; export const AudioRoomDescription = () => { return ( <View style={styles.container}> <Text>Audio Room Description: TO BE IMPLEMENTED</Text> </View> ); }; const styles = StyleSheet.create({ container: { padding: 4, }, });

Now add these components to AudioRoomUI. As you replace each TO BE IMPLEMENTED placeholder over the next few sections, delete the <Text> line it lived on along with any Text import that becomes unused - otherwise yarn lint flags them at the end:

src/AudioRoomUI.tsx (tsx)
1
2
3
4
5
6
7
8
-
9
+
10
+
11
+
12
13
14
15
... import { AudioRoomControlsPanel } from './AudioRoomControlsPanel'; import { AudioRoomDescription } from './AudioRoomDescription'; import { AudioRoomParticipants } from './AudioRoomParticipants'; ... return ( <View style={styles.container}> <Text style={styles.text}>Here we will add Audio Room UI</Text> <AudioRoomDescription /> <AudioRoomParticipants /> <AudioRoomControlsPanel /> <Button title="Leave Audio Room" onPress={goToHomeScreen} /> </View> );

Refresh the app and you'll see the three placeholders in place:

Preview of the audio room UI

Audio Room Description

useCallCustomData returns the data.custom you set when creating the call, and useParticipants gives you the participant list.

src/AudioRoomDescription.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
-
14
+
15
+
16
+
17
+
18
+
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React from "react"; import { StyleSheet, Text, View } from "react-native"; import { useCallStateHooks } from "@stream-io/video-react-native-sdk"; export const AudioRoomDescription = () => { const { useCallCustomData, useParticipants, useIsCallLive } = useCallStateHooks(); const custom = useCallCustomData(); const participants = useParticipants(); const isLive = useIsCallLive(); return ( <View style={styles.container}> <Text>Audio Room Description: TO BE IMPLEMENTED</Text> <Text style={styles.title}> {custom?.title} {isLive ? "(Live)" : "(Not Live)"} </Text> <Text style={styles.subtitle}>{custom?.description}</Text> <Text style={styles.count}>{`${participants.length} Participants`}</Text> </View> ); }; const styles = StyleSheet.create({ container: { padding: 4, alignContent: "center", alignItems: "center", }, title: { fontSize: 16, fontWeight: "bold", }, subtitle: { paddingVertical: 4, fontSize: 14, }, count: { fontSize: 12, }, });

Refresh and the title and participant count appear. Join and leave from the browser tab to watch the count update live on the device.

For testing you can join the call on our web-app:

Preview of the audio room UI

Checkpoint: the room title, description and participant count render, and the count changes when you join or leave from the browser.

Audio Room Participants

useParticipants gives you the list of participants and their speaking status.

src/AudioRoomParticipants.tsx (tsx)
1
2
3
4
5
+
6
+
7
8
9
10
-
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
23
24
25
26
27
28
29
30
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
49
import React from "react"; import { StyleSheet, Text, View, FlatList, Image } from "react-native"; import { useCallStateHooks } from "@stream-io/video-react-native-sdk"; export const AudioRoomParticipants = () => { const { useParticipants } = useCallStateHooks(); const participants = useParticipants(); return ( <View style={styles.container}> <Text>Audio Room Participants: TO BE IMPLEMENTED</Text> <FlatList numColumns={3} data={participants} renderItem={({ item }) => ( <View style={styles.avatar}> <Image style={[styles.image]} source={{ uri: item.image }} /> <Text style={styles.name}>{item.name}</Text> </View> )} keyExtractor={(item) => item.sessionId} /> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, padding: 4, }, name: { marginTop: 4, color: "black", fontSize: 12, fontWeight: "bold", }, avatar: { flex: 1, alignItems: "center", borderWidth: 4, borderColor: "transparent", }, image: { width: 80, height: 80, borderRadius: 40, }, });

In a real app you'll usually want the list sorted - dominant speaker first, alphabetically, or by your own rule. Pass a comparator to useParticipants:

tsx
1
2
3
4
import { speaking, useCallStateHooks } from "@stream-io/video-react-native-sdk"; const { useParticipants } = useCallStateHooks(); const participants = useParticipants({ sortBy: speaking });

The comparators (speaking and friends) are top-level exports, but useParticipants is not - like every call state hook it comes out of useCallStateHooks().

The Participants Sorting guide covers the available options.

Audio rooms normally show who is speaking. Each participant carries an isSpeaking boolean, so you can put a border around the active speaker's avatar:

src/AudioRoomParticipants.tsx (tsx)
1
2
3
4
5
6
7
8
9
+
10
+
11
+
12
+
13
14
... <Image style={[styles.image, item.isSpeaking && styles.activeSpeakerIndicator]} source={{uri: item.image}} /> ... const styles = StyleSheet.create({ ... activeSpeakerIndicator: { borderWidth: 4, borderColor: 'green', }, });

Refresh and the participant list renders:

Preview of the audio room UI

Checkpoint: avatars and names appear for everyone in the room, and a green border tracks whoever is speaking in the browser tab.

Audio Room Controls Panel

In Create and Join Call the room went live automatically. Move that onto a button so the host controls it.

Create src/ToggleLiveButton.tsx and make the accompanying changes to src/AudioRoomControlsPanel.tsx and src/CallScreen.tsx:

ToggleLiveButton
AudioRoomControlsPanel
CallScreen
src/ToggleLiveButton.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useCall, useCallStateHooks } from "@stream-io/video-react-native-sdk"; import React from "react"; import { Button } from "react-native"; export const ToggleLiveButton = () => { // this utility hook returns the call object from the <StreamCall /> context const call = useCall(); // will emit a new value whenever the call goes live or stops being live. // we can use it to update the button text or adjust any other UI elements const { useIsCallLive } = useCallStateHooks(); const isLive = useIsCallLive(); return ( <Button title={`${isLive ? "Stop" : "Go"} Live`} onPress={() => { if (isLive) { call?.stopLive(); } else { call?.goLive(); } }} /> ); };

Next, a mute button. Handling audio devices normally means working with MediaStream, MediaDeviceInfo and other WebRTC objects; the SDK hides that behind call.microphone.toggle() and the useMicrophoneState hook. The Camera and Microphone guide has the details.

Create src/ToggleMicButton.tsx and add it to AudioRoomControlsPanel:

ToggleMicButton
AudioRoomControlsPanel
src/ToggleMicButton.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useCall, useCallStateHooks } from "@stream-io/video-react-native-sdk"; import React from "react"; import { Button } from "react-native"; export const ToggleMicButton = () => { const call = useCall(); const { useMicrophoneState } = useCallStateHooks(); const { status: micStatus } = useMicrophoneState(); return ( <Button title={`${micStatus === "enabled" ? "Mute" : "Unmute"}`} onPress={() => call?.microphone.toggle()} /> ); };

The SDK routes audio-room audio to the speaker by default, so there is nothing to configure for the common case. If you do need to control routing yourself, callManager.start() has to run before the call joins - once join has activated the audio manager it rejects further configuration with "AudioManager is already activated". That means it belongs alongside the join() in CallScreen, not in a button's effect:

src/CallScreen.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
... import { callManager } from '@stream-io/video-react-native-sdk'; export const CallScreen = ({ goToHomeScreen, callId }: Props) => { ... useEffect(() => { callManager.start({ audioRole: "communicator", deviceEndpointType: "speaker", }); return () => { callManager.stop(); }; }, []); ... }

The Speaker management docs cover switching between earpiece and speaker at runtime.

Refresh and you can toggle live mode and mute or unmute the microphone.

Preview of the audio room UI

Human checkpoint: the device asks for microphone access the first time - allow it. Agents: build and launch, then ask the human to grant access and confirm they can be heard in the browser tab.

This is the payoff: a working audio room that goes live on demand, lists its participants, shows who's speaking, and carries your voice. On the shared tutorial credentials? Create your own Stream app - the free maker plan covers hobby projects, and Step 3 swaps the credentials over in two commands.

Step 8 - Requesting permission to speak

Listeners need a way to ask for the mic, and hosts need a way to answer. The listener calls call.requestPermissions(); the host listens for the call.permission_request event and responds with call.grantPermissions() or call.revokePermissions().

Here's how the call object exposes both sides:

Request Permission
Grant or Reject Permission
tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { OwnCapability } from "@stream-io/video-react-native-sdk"; // On user side who is requesting permission to speak await call.requestPermissions({ permissions: [OwnCapability.SEND_AUDIO], }); // Once the host grants or rejects permission, user will be notified using `call.permissions_updated` event const unsubscribe = call.on("call.permissions_updated", (event) => { if (connectedUser.id !== event.user.id) return; // automatically publish/unpublish audio stream based on the new permissions if (event.own_capabilities.includes(OwnCapability.SEND_AUDIO)) { call.microphone.enable(); } else { call.microphone.disable(); } });

Start with the listener side: when a user without the mic permission taps the microphone button, request it instead of toggling.

src/ToggleMicButton.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useCall, useCallStateHooks, OwnCapability, } from "@stream-io/video-react-native-sdk"; import React, { useState } from "react"; import { Button } from "react-native"; export const ToggleMicButton = () => { const call = useCall(); const { useMicrophoneState, useHasPermissions } = useCallStateHooks(); const { status: micStatus } = useMicrophoneState(); const hasPermission = useHasPermissions(OwnCapability.SEND_AUDIO); const canRequestSpeakingPermissions = call?.permissionsContext.canRequest( OwnCapability.SEND_AUDIO, ); // State to track if the user has requested speaking permissions. // If they have, we'll disable the toggle microphone button. const [isAwaitingAudioApproval, setIsAwaitingAudioApproval] = useState(false); const disabled = (!hasPermission && !canRequestSpeakingPermissions) || isAwaitingAudioApproval; return ( <Button title={`${micStatus === "enabled" ? "Mute" : "Unmute"}`} disabled={disabled} onPress={() => { if (!hasPermission) { setIsAwaitingAudioApproval(true); return call?.requestPermissions({ permissions: [OwnCapability.SEND_AUDIO], }); } call?.microphone.toggle(); }} /> ); };

Now listen for call.permissions_updated so the user finds out when a host grants or denies the request. Note that the user in this app already has permission to speak - the default configuration lets the call creator send audio - so you'll see this path exercised from the browser tab rather than the device.

src/ToggleMicButton.tsx (tsx)
1
2
+
3
4
5
6
7
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
24
25
import { ... useConnectedUser, } from '@stream-io/video-react-native-sdk'; export const ToggleMicButton = () => { ... const connectedUser = useConnectedUser(); useEffect(() => { if (!call || !connectedUser) return; const unsubscribe = call.on('call.permissions_updated', event => { if (connectedUser.id !== event.user.id) return; setIsAwaitingAudioApproval(false); // automatically publish/unpublish audio stream based on the new permissions if (event.own_capabilities.includes(OwnCapability.SEND_AUDIO)) { call.microphone.enable(); } else { call.microphone.disable(); } }); return () => unsubscribe(); }, [call, connectedUser]); ... }

For the host side, add a component that lists incoming requests with grant and deny buttons. It renders nothing unless the current user can update permissions, so listeners never see it.

Create src/PermissionsRequestsPanel.tsx:

src/PermissionsRequestsPanel.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { OwnCapability, PermissionRequestEvent, useCall, useCallStateHooks, } from "@stream-io/video-react-native-sdk"; import React, { useEffect, useState } from "react"; import { Text, Button, ScrollView, StyleSheet, View } from "react-native"; export const PermissionRequestsPanel = () => { const call = useCall(); const { useHasPermissions } = useCallStateHooks(); const canUpdatePermissions = useHasPermissions( OwnCapability.UPDATE_CALL_PERMISSIONS, ); const [speakingRequests, setSpeakingRequests] = useState< PermissionRequestEvent[] >([]); const handlePermissionRequest = async ( request: PermissionRequestEvent, approve: boolean, ) => { const { user, permissions } = request; try { if (approve) { await call?.grantPermissions(user.id, permissions); } else { await call?.revokePermissions(user.id, permissions); } setSpeakingRequests((reqs) => reqs.filter((req) => req !== request)); } catch (err) { console.error("Error granting or revoking permissions", err); } }; useEffect(() => { if (!call || !canUpdatePermissions) return; return call.on("call.permission_request", (request) => { setSpeakingRequests((requests) => [...requests, request]); }); }, [call, canUpdatePermissions]); if (!canUpdatePermissions || !speakingRequests.length) return null; return ( <ScrollView style={styles.scrollContainer}> {speakingRequests.map((request) => ( <View style={styles.itemContainer} key={request.user.id}> <Text style={styles.text} numberOfLines={2} ellipsizeMode="tail"> {`${request.user.name} requested to ${request.permissions.join(",")}`} </Text> <Button title="Approve" onPress={() => handlePermissionRequest(request, true)} /> <Button title="Deny" onPress={() => handlePermissionRequest(request, false)} /> </View> ))} </ScrollView> ); }; const styles = StyleSheet.create({ scrollContainer: { width: "100%", maxHeight: 60, }, text: { flexShrink: 1, }, itemContainer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 16, width: "100%", }, });

Add it to AudioRoomUI:

src/AudioRoomUI.tsx (tsx)
1
2
+
3
4
5
6
7
8
9
+
10
11
12
13
14
15
16
... import { AudioRoomParticipants } from './AudioRoomParticipants'; import { PermissionRequestsPanel } from './PermissionsRequestsPanel'; export const AudioRoomUI = ({goToHomeScreen}: Props) => { return ( <View style={styles.container}> <AudioRoomDescription /> <AudioRoomParticipants /> <PermissionRequestsPanel /> <AudioRoomControlsPanel /> <Button title="Leave Audio Room" onPress={goToHomeScreen} /> </View> ); }; ...

To test it:

  1. On the device, press Go Live first. Step 7 moved goLive() onto a button, so the room now starts backstage - a web visitor who arrives before you go live is turned away with "This room has been terminated".
  2. Open the web app and join the audio room.
  3. Click the raised-hand button in the top-right corner to request permission to speak.
  4. The request appears in the mobile app, with Approve and Deny buttons.
For testing you can join the call on our web-app:

Preview of the final result

Checkpoint: requesting the mic from the browser makes a row appear on the device. Approving it lets the browser user speak; denying it leaves them muted.

Step 9 - Leave the call

call.leave() disconnects the user. Wire it to the "Leave Audio Room" button:

src/AudioRoomUI.tsx (tsx)
1
2
+
3
4
5
6
+
7
+
8
+
9
+
10
+
11
+
12
13
14
15
16
17
18
19
+
20
21
22
23
... import { AudioRoomParticipants } from './AudioRoomParticipants'; import { useCall } from '@stream-io/video-react-native-sdk'; ... export const AudioRoomUI = ({goToHomeScreen}: Props) => { const call = useCall(); const leaveCall = async () => { // This will leave the call and stop sending and receiving audio. await call?.leave(); goToHomeScreen(); }; return ( <View style={styles.container}> <AudioRoomDescription /> <AudioRoomParticipants /> <PermissionRequestsPanel /> <AudioRoomControlsPanel /> <Button title="Leave Audio Room" onPress={leaveCall} /> </View> ); };

Checkpoint: tapping "Leave Audio Room" returns you to the home screen and drops your participant count in the browser tab.

Verify the whole build

Build to your device and run the full loop: join the room, allow microphone access, then join from a browser tab and watch the participant count change. Toggle Go Live and Stop Live. Speak and watch the green border follow you. Request the mic from the browser and approve it on the device. Leave, and confirm you land back home.

Other built-in features

There are a few more exciting features that you can use to build audio rooms

  • Query Calls: You can query calls to easily show upcoming calls, calls that recently finished as well as call previews.
  • Reactions & Custom events: Reactions and custom events are supported.
  • Chat: Stream's chat SDKs are fully featured and you can integrate them in the call
  • Moderation: Moderation capabilities are built-in to the product
  • Transcriptions: You can enable transcriptions for your calls

Troubleshooting

  • JoinBackstage permission error - an audio_room call starts backstage, and only host and admin can join before it goes live. Add the user as a member with role: 'host' (Step 6), grant them admin from your server, or disable backstage for the call type in the dashboard.
  • token is invalid / auth error - the token was minted for a different app, or it expired. Re-mint with getstream token <user-id> and confirm the API key matches.
  • project credentials missing - the CLI has not been initialized here. Run getstream init in the project directory first.
  • No audio, or the mic button does nothing - you're likely on an emulator. Emulators have limited audio device support; build to a physical device.
  • Microphone permission prompt never appears - NSMicrophoneUsageDescription is missing from Info.plist, or RECORD_AUDIO is missing from AndroidManifest.xml (Step 2).
  • iOS build fails to find a pod - run bundle install then bundle exec pod install from the ios folder, or npx pod-install from the project root.
  • Android build fails on minSdkVersion - set minSdkVersion = 24 in the buildscript.ext block of android/build.gradle.
  • Permission requests never reach the host - PermissionRequestsPanel returns null unless the current user has UPDATE_CALL_PERMISSIONS, which the host role carries.
  • Audio comes out of the earpiece instead of the speaker - the SDK routes to the speaker by default. If you are overriding it with callManager.start(), that call must run before join(), or it is rejected with "AudioManager is already activated" and silently does nothing (Step 7).

Recap

It was fun to see just how quickly you can build an audio-room for your app. Please do let us know if you ran into any issues. Our team is also happy to review your UI designs and offer recommendations on how to achieve it with Stream.

To recap what we've learned:

  • You set up a call with const call = client.call('audio_room', '123')
  • The call type audio_room controls which features are enabled and how permissions are set up
  • The audio_room by default enables backstage mode, and only allows admins and the creator of the call to join before the call goes live
  • When you join a call, real-time communication is set up for audio & video calling: await call.join()
  • Call state call.state and helper state access hooks make it easy to build your own UI
  • Calls run on Stream's global edge network of video servers. Being closer to your users improves the latency and reliability of calls. For audio rooms we use Opus RED and Opus DTX for optimal audio quality.

The SDKs enable you to build audio rooms, video calling and live-streaming in days.

We hope you've enjoyed this tutorial and please do feel free to reach out if you have any suggestions or questions.

Next steps

Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.

Final Thoughts

In this audio room tutorial we built a fully functioning React Native audio room with our React Native SDK, from backstage and go-live through raise-to-speak moderation.

Both the video SDK for React Native and the API have plenty more features available to support more advanced use cases.

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills and getstream skills stream-react-native for the React Native pack. (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream token <user-id> (mint a token). getstream env --target <expo|ios|android> writes keys into a project file where the platform has a convention for it.
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, calls, and call members
  • React Native integration skill: invoke /stream-react-native in your agent for React Native and Expo setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/video/docs/react-native/llms.txt
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.