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

iOS Audio Room Tutorial

The following tutorial shows you how to quickly build an Audio Room app leveraging the Stream's Video API and the Stream Video iOS components. The underlying API is very flexible and allows you to build nearly any type of video experience.

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

This tutorial will teach you how to build an audio room experience like Twitter Spaces or Clubhouse. The result will look like the image on the right and supports the following features:

  • Backstage mode: You can start the call with your co-hosts and chat a bit before going live.
  • Calls run on Stream's global edge network for optimal latency and scalability.
  • No cap on how many listeners you can have in a room.
  • Listeners can raise their hands and be invited by the host to speak.
  • Audio tracks are sent multiple times for optimal reliability.

Preview of the final result

Time to get started building an audio-room for your app.

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.

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, in Xcode. 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 step is required for Path A, not optional - the skills are what give Claude Code, Cursor, or Codex the iOS integration patterns and current SDK APIs, so the agent 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 iOS pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-swift

Then ask your agent:

Prompt (markdown)
1
2
3
4
5
/stream-swift Add a Stream audio room to my SwiftUI app: join a call of type audio_room, show the room title and description, split the participants into speakers and listeners, and add controls to go live and toggle my microphone. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.

Only the first line matters - /stream-swift Add a Stream audio room to my app is enough to get going. The rest just steers the result: ask for a raise-hand flow, a room list before joining, or your own avatar layout. Describe the UI you want rather than the UI the tutorial builds.

Where you come in. The agent writes the code, provisions the credentials, and edits Info.plist for the microphone usage description itself. It hands back to you twice: when getstream init opens your browser to log in and pick an app (new accounts get an organization created in that flow), and when the SPM package needs to be added in Xcode - hand-patching project.pbxproj is fragile, so the agent asks rather than risks it.

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

Checkpoint: build and run, allow microphone access when iOS asks, and press Go Live. Until you do, the room is in backstage and nobody else can get in - that's the audio_room default. Then join with a second client (another device or Simulator, signed in as a different user id with the same call id) and watch the participant list update.

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. Every code block is titled with the file or view it belongs to: the block marked with the lock icon is a complete file, the shorter ones replace only the view they name, and the untitled blocks are excerpts explaining what just changed.

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

Important Building Blocks

Five pieces, one mental model:

  • StreamVideo - the low-level client. Holds the API key, the user, the token and the websocket connection. Created once, at app launch.
  • Call - a single room, created with client.call(callType: "audio_room", callId:). call.join(create: true) is what actually sets up audio.
  • Backstage - the audio_room call type starts rooms not live. Hosts and co-hosts can join and talk first; call.goLive() is what lets everybody else in, and call.stopLive() closes it again.
  • call.state - the observable state of the room: participants, custom data, backstage, permissionRequests. This is what your SwiftUI views read from.
  • call.microphone - the MicrophoneManager for the local user. Publishes status and exposes toggle(), enable() and disable().

Permissions matter more here than in a video call: audio_room only lets admins and moderators speak, so everybody else calls call.request(permissions: [.sendAudio]) and a host grants it.

Unlike the video calling tutorial, there is no prebuilt container view for audio rooms - you compose the UI yourself out of call.state, which is exactly what Steps 4 to 8 do.

Step 0 - Get your credentials

The code in Step 3 needs four values: an API key, a user token, a user id and a call id. There are two ways to get them.

Agents: detect the project shape before touching anything, including the CLI (find . -maxdepth 3 -name "*.xcodeproj" -o -name "Package.swift"). The CLI writes into the project directory, so an Xcode project has to exist first. If the directory is empty with no Xcode project, stop and ask the human to create the app in Xcode (Step 1) - don't scaffold it yourself, and don't run getstream init until it's there.

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

The getstream CLI provisions all of it in one flow. Run these from your project directory.

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 login 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 logging in before continuing. It's required first - token and api commands fail with "stream project is not initialized" until it runs.

3. Grab your API key. This opens the app you just selected in the Stream dashboard, where the API key is shown. Paste it into the code in Step 3.

Terminal (bash)
1
getstream open

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing). This user creates the room, which makes them the host:

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

5. Pick a call id. Anything URL-safe works, for example my-first-room. Rooms are created the first time somebody joins with create: true, so there is nothing to provision up front.

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

Pasting the API key straight into your source is fine for this tutorial - it's a publishable key, not a secret. The user token is the one to be careful with: in production your backend mints it after login and the app fetches it, rather than shipping a hardcoded one.

Option 2 - Pre-filled tutorial credentials, no account

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

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

Step 1 - Create a New SwiftUI Application in Xcode

  1. Make sure you have Xcode installed and that you are running 26 or later.
  2. Open Xcode and select "Create a new Project".
  3. Select "iOS" as the platform and "App" as the type of Application.
  4. Name your project "AudioRooms" and select "SwiftUI" as the interface.

Checkpoint:

Terminal (bash)
1
2
3
4
# Read the scheme out of the project instead of hardcoding a name SCHEME=$(xcodebuild -list | awk '/Schemes:/{f=1; next} f && NF {gsub(/^[ \t]+|[ \t]+$/, ""); print; exit}') xcodebuild -scheme "$SCHEME" -destination 'generic/platform=iOS Simulator' build

Every xcodebuild command on this page resolves the scheme this way, so it works whether you named the project AudioRooms or you're adding Stream to an app you already have. It takes the first scheme xcodebuild -list reports - if your project defines several, run xcodebuild -list yourself and pass the one you want. Agents: resolve the scheme from xcodebuild -list; never assume AudioRooms.

Step 2 - Install the SDK & Setup Permissions

Next, you must add our SDK dependencies to your project using Swift Package Manager from Xcode.

  1. Click on "Add packages..." from the File menu.
  2. Add https://github.com/GetStream/stream-video-swift in the search bar.
  3. In "Dependency Rule" choose "Up to Next Major Version" and enter the latest 1.x release.
  4. Select "StreamVideo" and "StreamVideoSwiftUI" and then click Add Package.

Pin the major explicitly, so a future 2.0 can't be picked up silently.

Agents: editing project.pbxproj by hand to add an SPM dependency is fragile. For a plain .xcodeproj with no Package.swift, ask the human to add the package in Xcode (30 seconds) rather than patching the pbxproj. The Info.plist key below is a different matter - edit that in place yourself.

Configure App Permissions

Joining an audio room requires microphone access; you must request permission to use it in your app. Add the following key to the Info.plist file to do this.

  • Privacy - Microphone Usage Description - "AudioRooms requires microphone access to capture and transmit audio".

Screenshot shows permissions in the .plist file

Checkpoint: xcodebuild -resolvePackageDependencies succeeds and resolves a 1.x version, import StreamVideo compiles, and the usage-description key shows up in the target's Info tab. Missing that key crashes the app the moment it tries to open the microphone.

Step 3 - Create & Join a Call

Open up AudioRooms/AudioRoomsApp.swift and replace it with this code:

AudioRooms/AudioRoomsApp.swift (swift)
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
import SwiftUI import StreamVideo @main struct AudioRoomsApp: App { @State var call: Call @ObservedObject var state: CallState @State private var callCreated: Bool = false private var client: StreamVideo private let apiKey: String = "REPLACE_WITH_API_KEY" private let userId: String = "REPLACE_WITH_USER_ID" private let token: String = "REPLACE_WITH_TOKEN" private let callId: String = "REPLACE_WITH_CALL_ID" init() { let user = User( id: userId, name: "Martin", // name and imageURL are used in the UI imageURL: .init(string: "https://getstream.io/static/2796a305dd07651fcceb4721a94f4505/a3911/martin-mitrevski.webp") ) // Initialize Stream Video client self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) ) // Initialize the call object let call = client.call(callType: "audio_room", callId: callId) self.call = call self.state = call.state } var body: some Scene { WindowGroup { VStack { if callCreated { Text("Audio Room \(call.callId) has \(call.state.participantCount) participants") .font(.system(size: 30)) .foregroundColor(.blue) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) callCreated = true } } } } }

If you followed Option 1 in Step 0, replace the four constants with your own values. If you followed Option 2, they're already filled in for you.

Let's review the example above and go over the details.

User setup. First we create a user object. You typically sync your users via a server side integration from your own backend. Alternatively, you can use guest or anonymous users.

swift
1
2
3
4
let user = User( id: userId, name: "Martin" )

Client init. Next we initialize the client by passing the API Key, user and user token.

swift
1
2
3
4
5
self.client = StreamVideo( apiKey: apiKey, user: user, token: .init(stringLiteral: token) )

Create and join call After the user and client are created, we create a call like this:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.call = client.call(callType: "audio_room", callId: callId) try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) )
  • This joins and creates a call with the type: "audio_room" and the specified callId.
  • The users with id john_smith and jane_doe are added as members to the call.
  • We set the title and description custom fields on the call object.

Your server-side API typically generates the user token. When a user logs in to your app, you return the user token, which gives them access to the call. We have generated the token for you in the code sample above to make this tutorial easier to follow - never ship the API secret to the app.

With valid credentials in place, we can join the call. When you run the app, you'll see the following:

Preview of the app after step 3

Human checkpoint: the microphone prompt is a system dialog. Agents: build and install the app, then ask the human to launch it and allow microphone access.

Checkpoint: the screen reads Audio Room <call-id> has 1 participants and the Xcode console is free of auth errors. Stuck on loading...? The join never completed - check the console, which usually means the API key and token belong to different apps.

Note that the room is created in backstage: you are in it because you created it, but nobody else can get in yet. Step 4 adds the button that changes that.

Step 4 - Add the Audio Room UI Elements

In this next step, we'll add the following.

  • Room title and description.
  • Controls to toggle the live mode on/off.
  • A list of participants with their speaking status.

Room Title & Description

Let's create the components we need to render this and add them to the main app view.

DescriptionView (swift)
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
import SwiftUI import StreamVideo struct DescriptionView: View { var title: String? var description: String? var participants: [CallParticipant] var body: some View { VStack { VStack { Text("\(title ?? "")") .font(.title) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) .padding([.bottom], 8) Text("\(description ?? "")") .font(.body) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) .padding([.bottom], 4) Text("\(participants.count) participants") .font(.caption) .frame(maxWidth: .infinity, alignment: .leading) }.padding([.leading, .trailing]) } } }
ParticipantsView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
import SwiftUI import StreamVideo struct ParticipantsView: View { var participants: [CallParticipant] var body: some View { Spacer() Text("Participants: TODO").font(.body) Spacer() } }
ControlsView (swift)
1
2
3
4
5
6
7
8
9
10
11
import SwiftUI import StreamVideo struct ControlsView: View { @State var call: Call @ObservedObject var state: CallState var body: some View { Text("Controls: TODO").font(.body) } }

That's it for the basics. Here's how the app Scene should look like now.

swift
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
var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) ParticipantsView( participants: call.state.participants ) Spacer() ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) callCreated = true } } } }

If you run the app now, it should look like this.

Preview of the app after step 4

The approach is the same for all components. We observe call.state published variables, such as call.state.participants, to determine the call's states and use them to power our UI.

Checkpoint: the room title "SwiftUI heads" and its description render above a participant count, with the two placeholder views below them.

Let's join the audio room from the browser to make this a little more interactive.

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

Using your own credentials from Option 1? The Join button above joins the shared tutorial room, not yours. Run the app on a second device (or simulator) with a different user id and token, and the same call id. Two clients signed in as the same user count as one participant.

Checkpoint: the browser refuses to let you in, and the participant count stays at 1. That's expected - the room is still in backstage. The next section adds the button that opens it.

Backstage & Live Mode Control

As you probably noticed by opening the same room from the browser, audio rooms are not live by default. Regular users can only join an audio room when it is in live mode. Let's expand the ControlView and add a button that controls the backstage or the room.

MicButtonView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI import StreamVideo struct MicButtonView: View { var body: some View { Button { Task { print("handle mic tap") } } label: { Label("", systemImage: "mic.circle").font(.title) } } }
LiveButtonView (swift)
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
import SwiftUI import StreamVideo struct LiveButtonView: View { var call: Call @ObservedObject var state: CallState var body: some View { if state.backstage { Button { Task { try await call.goLive() } } label: { Text("Go Live") } .buttonStyle(.borderedProminent).tint(.green) } else { Button { Task { try await call.stopLive() } } label: { Text("Stop live") } .buttonStyle(.borderedProminent).tint(.red) } } }
ControlsView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI import StreamVideo struct ControlsView: View { var call: Call @ObservedObject var state: CallState var body: some View { HStack { MicButtonView() LiveButtonView(call: call, state: state) } } }

Now, the app exposes a fake mic control button (more on that later) and a button to toggle live mode on/off. If you try the web demo of the audio room, you should be able to join as a regular user.

Preview of the app with live button

Checkpoint: tapping Go Live flips the button to a red Stop live, and the browser tab from the previous section can now join. Tap Stop live and the button flips back. state.backstage is what drives both.

List Participants

As a next step, let's render the actual list of participants and show an indicator when they are speaking. To do this, we will create a ParticipantView and render it from the ParticipantsView.

ParticipantView (swift)
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
import SwiftUI import StreamVideo struct ParticipantView: View { var participant: CallParticipant var body: some View { VStack{ ZStack { Circle() .fill(participant.isSpeaking ? .green : .white) .frame(width: 68, height: 68) AsyncImage( url: participant.profileImageURL, content: { image in image.resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 64, maxHeight: 64) .clipShape(Circle()) }, placeholder: { Image(systemName: "person.crop.circle").font(.system(size: 60)) } ) } Text("\(participant.name)") } } }
ParticipantsView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI import StreamVideo struct ParticipantsView: View { var participants: [CallParticipant] var body: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 20) { ForEach(participants) { ParticipantView(participant: $0) } } } }

With these changes, things get more interesting. The app now shows a list of all participants connected to the call and displays a small circle next to the ones who are speaking.

Preview of the app with participants list

Checkpoint: every participant renders as an avatar with their name underneath, and the circle behind whoever is talking turns green. That highlight is participant.isSpeaking, which the SDK updates from the audio level - no wiring needed on your side.

Step 5 - Go live and join from the browser

If you join the call from the browser, the participant list will now update as you open and close the browser tab.

Note how the web interface won't let you share your audio/video. By default, the audio_room call type only allows moderators or admins to speak. Regular participants can request permission. If different defaults make sense for your app, you can edit the call type in the dashboard or create your own.

Checkpoint: with the room live, opening and closing the browser tab moves the participant count between 1 and 2, and the browser participant appears in and disappears from the grid.

Step 6 - Requesting permission to speak

Requesting permission to speak is easy. Let's first have a quick look at how the SDK call object exposes this.

Requesting Permission To Speak

swift
1
let response = try await call.request(permissions: [.sendAudio])

Handling Permission Requests

Permission requests are exposed from call.state on the permissionRequests published variable.

swift
1
2
3
4
5
6
7
if let request = call.state.permissionRequests.first { // reject it request.reject() // grant it try await call.grant(request: request) }

Let's add another view that shows the last incoming request as well as the buttons to grant or reject it.

PermissionRequestsView (swift)
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 SwiftUI import StreamVideo struct PermissionRequestsView: View { var call: Call @ObservedObject var state: CallState var body: some View { if let request = state.permissionRequests.first { HStack { Text("\(request.user.name) requested to \(request.permission)") Button { Task { try await call.grant(request: request) } } label: { Label("", systemImage: "hand.thumbsup.circle").tint(.green) } Button(action: request.reject) { Label("", systemImage: "hand.thumbsdown.circle.fill").tint(.red) } } } } }

Here is the updated Scene code that includes it.

swift
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
var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) ParticipantsView( participants: call.state.participants ) Spacer() PermissionRequestsView(call: call, state: state) ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) callCreated = true } } } }

Now when a user requests access to speak the app will look like this.

Permission requests

Checkpoint: raise your hand from the browser tab and the request row appears in the iOS app. Tap the thumbs-up and the browser participant can unmute; tap the thumbs-down and the row disappears without granting anything. More on the model behind this in permissions and moderation.

Step 7 - Add a Microphone Control

You can read & manage the microphone status from the call.microphone published variable.

swift
1
2
3
4
5
6
7
8
9
10
11
/// Read the microphone's status let isEnabled = call.microphone.status == .enabled /// Toggle between enabled/disabled microphone status. try await call.microphone.toggle() /// Enable the microphone. try await call.microphone.enable() /// Disable the microphone. try await call.microphone.disable()

Let's update the MicButtonView with microphone handling.

MicButtonView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI import StreamVideo struct MicButtonView: View { @ObservedObject var microphone: MicrophoneManager var body: some View { Button { Task { try await microphone.toggle() } } label: { Image(systemName: microphone.status == .enabled ? "mic.circle" : "mic.slash.circle") .foregroundColor(microphone.status == .enabled ? .red : .primary) .font(.title) } } }

We can now pass the microphone ObservedObject from the ControlsView.

ControlsView (swift)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI import StreamVideo struct ControlsView: View { var call: Call @ObservedObject var state: CallState var body: some View { HStack { MicButtonView(microphone: call.microphone) LiveButtonView(call: call, state: state) } } }

Checkpoint: the mic button now flips between mic.circle and mic.slash.circle as you tap it, and muting yourself is audible in the browser tab. Nothing happens at all? iOS denied the microphone permission - check Settings, or see camera and microphone.

Step 8 - Render Group Participants

It is common for audio rooms and similar interactive audio/video experiences to show users in separate groups. Let's see how we can update this application to render participants in two separate sections: speakers and listeners.

Building custom layouts is very simple. All we need to do is apply some filtering to the call.participants observable.

swift
1
2
3
4
5
6
7
8
// a list of participants, by default this is list is ordered by the ID of the user call.state.participants // Speakers: participants that have an audio track (ie. are allowed to speak and have a mic configured) call.state.participants.filter { $0.hasAudio } // Listeners: participants that do not have an audio track call.state.participants.filter { !$0.hasAudio }

We already have a view to display participants so all we need to do is to create another one. Here's how the scene body looks.

swift
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
var body: some Scene { WindowGroup { VStack { if callCreated { DescriptionView( title: call.state.custom["title"]?.stringValue, description: call.state.custom["description"]?.stringValue, participants: call.state.participants ) Text("Speakers") Divider() ParticipantsView( participants: call.state.participants.filter {$0.hasAudio} ) Text("Listeners") Divider() ParticipantsView( participants: call.state.participants.filter {!$0.hasAudio} ) Spacer() PermissionRequestsView(call: call, state: state) ControlsView(call: call, state: state) } else { Text("loading...") } }.task { Task { guard !callCreated else { return } try await call.join( create: true, options: .init( members: [ .init(userId: "john_smith"), .init(userId: "jane_doe"), ], custom: [ "title": .string("SwiftUI heads"), "description": .string("Talking about SwiftUI") ] ) ) try await call.sendReaction(type: "raise-hand", custom: ["mycustomfield": "hello"], emojiCode: ":smile:") callCreated = true } } } }

SplitUI

The sendReaction line at the end of the join is there to show that reactions work on audio rooms too. Drop it if you don't want a raise-hand reaction fired every time somebody joins.

Checkpoint: the grid is split under two headings. You sit under Speakers once you are live and unmuted; a browser participant who has not been granted sendAudio sits under Listeners and moves up the moment you grant their request.

Explore 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.
  • Recording & Broadcasting: You can record your rooms and broadcast them over HLS or RTMP.
  • Chat: Stream's Chat SDKs are fully featured, and you can integrate them in the room.
  • Moderation: Moderation capabilities are built into the product.
  • Transcriptions and closed captions: Turn what is said in the room into text, live or after the fact.
  • Noise cancellation: A plugin that strips background noise out of participants' microphones.

Verify the whole build

Terminal (bash)
1
2
3
4
5
SCHEME=$(xcodebuild -list | awk '/Schemes:/{f=1; next} f && NF {gsub(/^[ \t]+|[ \t]+$/, ""); print; exit}') xcodebuild -scheme "$SCHEME" \ -destination 'generic/platform=iOS Simulator' \ build

The Simulator is enough to prove the app compiles and joins - unlike video, an audio room needs no camera. Run it on a device to confirm the full loop: join the room -> press Go Live -> the browser participant appears -> grant their request to speak -> they move from Listeners to Speakers.

Troubleshooting

  • stream project is not initialized - CLI onboarding not run. Run getstream init in the project directory first.
  • xcodebuild: error: The project ... does not contain a scheme named "" - $SCHEME came back empty, so xcodebuild -list found no project (wrong directory) or more than one. cd to the folder holding your .xcodeproj, run xcodebuild -list, and pass a scheme from its output explicitly.
  • Stuck on loading... - the join never completed. Check the Xcode console; an auth error almost always means the API key and token belong to different apps.
  • token is invalid / auth error - token minted for a different app or expired. Re-mint with getstream token <user_id> and confirm the API key matches.
  • App crashes as soon as you join - missing usage description. Add Privacy - Microphone Usage Description (Step 2).
  • The browser can't join the room - the room is still in backstage. Press Go Live in the app. This is audio_room's default and it is deliberate.
  • No "Go Live" button, or goLive() fails - your user isn't the room's creator or an admin. Only they can go live; regular participants can't.
  • The browser participant can't speak - that's the audio_room default. They request sendAudio and you grant it from PermissionRequestsView (Step 6).
  • Participant count stays at 1 - the two clients are on different call ids, or both are signed in as the same user id. Two clients with one user id count as one participant.
  • Mic button does nothing - microphone access was denied. Check Settings, and confirm the usage-description key is present.
  • Missing package product 'StreamVideoSwiftUI' - package resolution stale. File -> Packages -> Reset Package Caches, or run xcodebuild -resolvePackageDependencies.
  • Nobody ever shows as speaking - isSpeaking only flips for participants who actually publish audio. A listener with no sendAudio permission never lights up.

Recap

It was fun to see how quickly you can build an audio-room for your app. Please 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 integrate them with Stream.

Let's recap what we've learned.

  • You set up a call with call = client.call(callType: "audio_room", callId: "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, realtime communication is set up for audio & video calling with call.join().
  • State objects in call.state and call.state.participants make it easy to build your custom UIs.
  • All 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 livestreaming in days.

We hope you've enjoyed this tutorial. Please feel free to reach out if you have any suggestions or questions. You can find the code for this tutorial in this GitHub repository.

Next steps

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

Final Thoughts

This is one of our video/voice app tutorials. We built a fully functioning audio room app with our iOS SDK components library. We also showed how easy it is to customize behavior and styles of the audio room app with minimal code changes.

Both the Video SDK for iOS and the API have more features supporting 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-swift for the iOS pack. (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream open (dashboard, to read the API key) -> getstream token <user_id> (mint a token). The publishable API key goes straight into the source for a tutorial app; the token comes from your backend in production.
  • Call type: audio_room - starts in backstage, and only admins and moderators may send audio. Regular participants request sendAudio and a host grants it. Both defaults are editable per call type.
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, call types, and call members
  • iOS integration skill: invoke /stream-swift in your agent for SwiftUI/UIKit setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/video/docs/ios/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 pinned 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.