This tutorial will teach you how to build an audio room experience like Twitter Spaces or Clubhouse. The end result will support 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.
- UI components are fully customizable, as demonstrated in the Flutter Video Cookbook.
You can find the full code for the audio room tutorial on the Flutter Video Tutorials repository.
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. Both paths end with a working audio room - Path A's is shaped by what you ask for, Path B's by the steps below - so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
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 Flutter integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal
Then ask your agent:
12345/stream-flutter Build a Flutter audio room app: create and join a call of type audio_room, render the participants with speaking indicators, add controls to go live and toggle my microphone, and let listeners request permission to speak so the host can grant it. Provision credentials with the CLI: create or select my org and app, then mint a token. If you can't mint one, ask me to provide fallback credentials.
Only the first line matters - /stream-flutter Build a Flutter audio room app is enough to get going. The rest just steers the result: ask for a room title and description, a speakers-and-listeners split, a room list before joining, or your own avatar layout. Describe the UI you want rather than the UI the tutorial builds.
If the CLI can't mint credentials, provide the fallback ones from Step 3, Option 2 when the agent asks.
Where you come in. The agent writes the code, provisions the credentials, and edits AndroidManifest.xml and Info.plist for the microphone permission itself. It hands back to you twice during setup - browser sign-in, and picking which org and app to use during getstream init (new accounts get an organization created in that flow) - and once more at the end, when the app runs on a device and you grant microphone access. Depending on what your agent is allowed to run, it may also ask you to run flutter create or flutter run yourself.
Already have your Flutter project? You can get the sign-in out of the way first: run getstream init from inside the project before you prompt the agent, and it picks up the initialized project from there.
Checkpoint: build and run, allow microphone access when asked, and confirm the room is live. audio_room calls start in backstage, where nobody else can get in, so your app either goes live as it creates the room or gives you a control for it - check whichever one it built. Then join that same room from a second client (another device or emulator, signed in as a different user id) and watch the participant list update. If your app generates its own room id, you'll need it visible on screen to join from the second client - ask your agent for that if it isn't there.
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 want to understand what the SDK is doing. Follow the steps below.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Flutter Video SDK is athttps://getstream.io/video/docs/flutter/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, inmain(), and reachable anywhere asStreamVideo.instance.Callof typeaudio_room- one room, created withStreamVideo.instance.makeCall(callType: StreamCallType.audioRoom(), id:).call.getOrCreate()registers it on the backend, andcall.join()is what actually sets up audio.- Backstage - the
audio_roomcall type starts rooms not live. Hosts and co-hosts can join and talk first;call.goLive()is what lets everybody else in, andcall.stopLive()closes it again. call.state- the observable state of the room.call.state.valueis the current snapshot andcall.state.valueStreamemits on every change, which is what your widgets read from. It carriescallParticipants,isBackstage,createdByMeand more.- Permissions -
audio_roomonly letshost,adminandspeakerroles send audio. Everybody else callscall.requestPermissions([CallPermission.sendAudio]), and a host grants it withcall.grantPermissions().
There is no prebuilt container widget for audio rooms - you compose the UI yourself out of call.state, which is exactly what Steps 6 to 10 do.
Step 1 - Create a New Flutter Project
To begin developing your audio room app, you need to create a new Flutter project. If you do not have Flutter or an IDE configured to work with it, we highly recommend following the Install and Set up an editor steps from the official documentation.
Please make sure you are using the latest version of Flutter from the stable channel:
12flutter channel stable flutter upgrade
Now create the project. For this tutorial we are calling it 'audioroom_tutorial':
12flutter create audioroom_tutorial --empty cd audioroom_tutorial
Checkpoint: you have an audioroom_tutorial directory and flutter run builds the Flutter starter app.
Step 2 - Install the SDK and Declare Permissions
Next, add Stream Video to your dependencies:
1flutter pub add stream_video_flutter
You should now have the dependency in your pubspec.yaml. flutter pub add writes the current version for you; if you add it by hand instead, replace ^latest with a real constraint:
123456dependencies: flutter: sdk: flutter # Replace ^latest with the current version from pub.dev, for example ^1.4.2 stream_video_flutter: ^latest
Stream offers several packages for integrating video capabilities into your application:
- stream_video_flutter: Contains pre-built UI components for quick implementation
- stream_video: The core client SDK for direct API access.
stream_video_flutterre-exports it, so it arrives with the dependency above and you don't list it separately. - stream_video_push_notification: Provides push notification support and CallKit integration. We won't be using it in this tutorial.
Checkpoint: flutter pub get completes and import 'package:stream_video_flutter/stream_video_flutter.dart'; resolves.
Declare Permissions
Before proceeding, you need to add the required permissions for audio functionality to your app.
For Android, update your AndroidManifest.xml file by adding these permissions:
12345678910111213<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET"/> <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.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> ... </manifest>
For iOS, open your Info.plist file and add:
12345678910<key>NSMicrophoneUsageDescription</key> <string>Microphone access is needed to speak in audio rooms</string> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>fetch</string> <string>processing</string> <string>remote-notification</string> <string>voip</string> </array>
stream_video_flutter requires iOS 14.0, and flutter create scaffolds the project at 13.0, so raise the deployment target or the build fails. Set Minimum Deployments to 14.0 in Xcode, or change all three occurrences of IPHONEOS_DEPLOYMENT_TARGET in ios/Runner.xcodeproj/project.pbxproj to 14.0.
Run the App
Emulators have limited audio device support, so use a physical device to actually hear and be heard. The first iOS device build also needs a signing team: open ios/Runner.xcworkspace in Xcode and, under Signing & Capabilities, select your Team and change the bundle identifier if the default is taken.
1flutter run
Human checkpoint: the app builds and launches on your device. Agents: run the build, then ask the human to confirm it launched.
Step 3 - Get Your Credentials
The code ahead needs four values: an API key, a user token and a user id in Step 5, plus a call id in Step 6. 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 audioroom_tutorial directory you created in Step 1 - the CLI stores project credentials there.
1. Install the CLI (skip if you did this in Path A):
1curl -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.
1getstream 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 and api fail with "project credentials missing", and open fails 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 5.
1getstream 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:
12getstream token john_doe getstream token john_doe --ttl 1d
5. Pick a call id. Anything URL-safe works, for example my-first-room. Rooms are created the first time somebody calls getOrCreate(), 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 sign-in and the app fetches it, rather than shipping a hardcoded one. The Client and Authentication guide covers the production shape.
Option 2 - Pre-filled tutorial credentials, no account
Want to skip account setup entirely? Every 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.
To run the application, we need a valid user token. In a production app, this token would typically be generated by your backend API when a user logs in.
For simplicity in this tutorial, we'll provide a way to generate a user token:
Here are credentials to try out the app with:
| Property | Value |
|---|---|
| API Key | Waiting for an API key ... |
| Token | Token is generated ... |
| User ID | Loading ... |
| Call ID | Creating random call ID ... |
Swap in your own credentials from Option 1 before building anything real. Tutorial credentials are shared and short lived.
Step 4 - Setup a Starter UI
Start with a basic UI: a home screen with a button, and a placeholder audio room screen to navigate to. Both get wired up to the SDK in the steps that follow.
Create a new file named lib/audio_room_screen.dart to hold the audio room screen, then copy the following content into each file, replacing whatever is there (the header of each block names the file):
12345678910111213141516171819202122232425262728293031323334import 'package:flutter/material.dart'; void main() { runApp( const MaterialApp( home: HomeScreen(), ), ); } class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { Widget build(BuildContext context) { return Scaffold( body: Center( child: ElevatedButton( onPressed: () => _createAudioRoom(), child: const Text('Create an Audio Room'), ), ), ); } Future<void> _createAudioRoom() async { // Step 6 fills this in. } }
123456789101112131415161718192021import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class AudioRoomScreen extends StatefulWidget { const AudioRoomScreen({ super.key, required this.audioRoomCall, }); final Call audioRoomCall; State<AudioRoomScreen> createState() => _AudioRoomScreenState(); } class _AudioRoomScreenState extends State<AudioRoomScreen> { Widget build(BuildContext context) { return const Placeholder(); } }
In the AudioRoomScreen widget we take the call created in Step 6 as a parameter. Step 6 is also where main.dart starts importing and navigating to it, and Step 7 replaces the Placeholder() with the participant grid.
Checkpoint: the app shows the home screen with a "Create an Audio Room" button. The button does nothing yet - Step 6 wires it up.
Step 5 - Set Up the Stream Video Client
Now, let's import the package and initialize the Stream client with your credentials.
Add the SDK import and rewrite main() in lib/main.dart. Everything below it - HomeScreen and _HomeScreenState - stays exactly as you left it in Step 4, so this is a merge, not a whole-file replacement:
123456789101112131415161718192021222324252627import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; Future<void> main() async { // Ensure Flutter is able to communicate with Plugins WidgetsFlutterBinding.ensureInitialized(); // Initialize Stream video and set the API key for our app. StreamVideo( 'REPLACE_WITH_API_KEY', user: const User( info: UserInfo( name: 'John Doe', id: 'REPLACE_WITH_USER_ID', ), ), userToken: 'REPLACE_WITH_TOKEN', ); runApp( const MaterialApp( home: HomeScreen(), ), ); } // HomeScreen and _HomeScreenState from Step 4 stay unchanged.
Checkpoint: the app still builds and launches. StreamVideo.instance.currentUser.id returns the user id you minted the token for - drop it into a Text widget if you want to see it, then remove it again.
Step 6 - Create & Join the Room
Now we can fill in the _createAudioRoom method left empty in Step 4, so the button creates a room, joins it, and opens the audio room screen.
To do this, we have to do a few things:
- Create a call with a type of
audio_roomand pass in an ID for the call. - Create the call on Stream's servers using
call.getOrCreate(), adding the current user as a host. - Configure and join the call with camera/microphone settings.
- If call is successfully created, join the call and use
call.goLive()to start the audio room immediately. - Navigate to the page for displaying the audio room once everything is created properly.
If you do not call call.goLive(), an audio_room call will be started in backstage mode, meaning the call hosts can join and see each other but the call will be invisible to others.
Here is what all of the above looks like in code. Replace the empty _createAudioRoom from Step 4 with this, and add import 'package:audioroom_tutorial/audio_room_screen.dart'; at the top of lib/main.dart so AudioRoomScreen resolves:
123456789101112131415161718192021222324252627282930313233343536373839404142Future<void> _createAudioRoom() async { // Set up our call object final call = StreamVideo.instance.makeCall( callType: StreamCallType.audioRoom(), id: 'REPLACE_WITH_CALL_ID', ); // Create the call and set the current user as a host final result = await call.getOrCreate( members: [ MemberRequest( userId: StreamVideo.instance.currentUser.id, role: 'host', ), ], ); if (result.isSuccess) { // Set some default behavior for how our devices should be configured once we join a call. // Note that the camera will be disabled by default because of the `audio_room` call type configuration. final connectOptions = CallConnectOptions( microphone: TrackOption.enabled(), ); await call.join(connectOptions: connectOptions); // Allow others to see and join the call (exit backstage mode) await call.goLive(); if (mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => AudioRoomScreen( audioRoomCall: call, ), ), ); } } else { debugPrint('Not able to create a call.'); } }
Checkpoint: tapping "Create an Audio Room" navigates to the placeholder room screen, which means getOrCreate(), join() and goLive() all succeeded. Still on the home screen? Check the console; an auth error almost always means the API key and token belong to different apps.
Step 7 - Build the Audio Room Screen
In this example, we'll create an audio room screen that shows all current participants in the room. The screen will include functionality for users to leave the audio room, toggle their microphone on/off, transition the call between live and backstage modes, and manage permission requests from other users.
Let's start by creating a basic audio room screen widget that takes the call object as a parameter.
This widget will listen to the call's state changes through call.state.valueStream, allowing us to react to any updates in the audio room in real-time.
Replace the contents of lib/audio_room_screen.dart with the following - this fills in the Placeholder() shell from Step 4 with the real screen:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class AudioRoomScreen extends StatefulWidget { const AudioRoomScreen({ super.key, required this.audioRoomCall, }); final Call audioRoomCall; State<AudioRoomScreen> createState() => _AudioRoomScreenState(); } class _AudioRoomScreenState extends State<AudioRoomScreen> { late CallState _callState; void initState() { super.initState(); _callState = widget.audioRoomCall.state.value; } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Audio Room: ${_callState.callId}'), leading: IconButton( onPressed: () async { await widget.audioRoomCall.leave(); if (context.mounted) { Navigator.of(context).pop(); } }, icon: const Icon( Icons.close, ), ), ), body: StreamBuilder<CallState>( initialData: _callState, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { // ... }, ), ); } }
In this code sample, we display the ID of the call using the existing CallState in an AppBar at the top of the Scaffold.
Additionally, there is also a leading close action on the AppBar which leaves the audio room.
Next, inside the StreamBuilder, we can display the grid of participants if the state is retrieved correctly.
If retrieval fails or is still in progress, we can display a failure message or a loading indicator respectively.
123456789101112131415161718192021222324252627282930313233StreamBuilder<CallState>( initialData: _callState, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { if (snapshot.hasError) { return const Center( child: Text('Cannot fetch call state.'), ); } if (snapshot.hasData && !snapshot.hasError) { var callState = snapshot.data!; return GridView.builder( itemBuilder: (BuildContext context, int index) { return Align( widthFactor: 0.8, child: ParticipantAvatar( participantState: callState.callParticipants[index], ), ); }, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, ), itemCount: callState.callParticipants.length, ); } return const Center( child: CircularProgressIndicator(), ); }, ),
For displaying the participants, we are creating a custom ParticipantAvatar widget that takes the participantState as a parameter:
12345678910111213141516171819202122232425262728293031323334353637383940class ParticipantAvatar extends StatelessWidget { const ParticipantAvatar({ required this.participantState, super.key, }); final CallParticipantState participantState; Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.linear, decoration: BoxDecoration( border: Border.all( color: participantState.isSpeaking ? Colors.green : Colors.white, width: 2, ), shape: BoxShape.circle, ), padding: const EdgeInsets.all(2), child: CircleAvatar( radius: 40, backgroundImage: participantState.image != null && participantState.image!.isNotEmpty ? NetworkImage(participantState.image!) : null, child: participantState.image == null || participantState.image!.isEmpty ? Text( participantState.name.substring(0, 1).toUpperCase(), style: const TextStyle( color: Colors.white, fontSize: 20, ), ) : null, ), ); } }
The ParticipantAvatar widget displays a circular avatar with a border that changes color based on whether the participant is speaking.
It also shows the participant's name or initials if an image is not available.
Checkpoint: the Placeholder() is gone and the room screen shows one tile in the grid - the first letter of your name, since the user you set up in Step 5 has no avatar image. The border turns green while you talk - that's participantState.isSpeaking, which the SDK updates from the audio level, with no wiring needed on your side. The idle border is white, so on a light background you'll only notice it once it turns green.
Step 8 - Add Microphone and Go-Live Controls
Lets add a floating action buttons to control the microphone and backstage mode.
123floatingActionButton: AudioRoomActions( audioRoomCall: widget.audioRoomCall, ),
The AudioRoomActions widget is a custom widget that takes the audioRoomCall as a parameter.
It contains a FloatingActionButton for controlling the microphone and a FloatingActionButton.extended for controlling the backstage mode.
Append it to lib/audio_room_screen.dart, and add import 'dart:async'; alongside the imports at the top of the file:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133class AudioRoomActions extends StatefulWidget { const AudioRoomActions({required this.audioRoomCall, super.key}); final Call audioRoomCall; State<AudioRoomActions> createState() => _AudioRoomActionsState(); } class _AudioRoomActionsState extends State<AudioRoomActions> { var _microphoneEnabled = false; var _waitingForPermission = false; StreamSubscription? _callEventsSubscription; void initState() { super.initState(); _microphoneEnabled = widget.audioRoomCall.connectOptions.microphone.isEnabled; _callEventsSubscription = widget.audioRoomCall.callEvents.on<StreamCallPermissionsUpdatedEvent>(( event, ) { if (event.user.id != StreamVideo.instance.currentUser.id) { return; } if (_waitingForPermission && event.ownCapabilities.contains(CallPermission.sendAudio)) { setState(() { _waitingForPermission = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Permission to speak granted. You can now enable your microphone.', ), ), ); } }); } Widget build(BuildContext context) { return StreamBuilder<CallState>( initialData: widget.audioRoomCall.state.value, stream: widget.audioRoomCall.state.valueStream, builder: (context, snapshot) { final callState = snapshot.data; if (callState == null) { return const SizedBox.shrink(); } return Row( mainAxisAlignment: MainAxisAlignment.end, spacing: 20, children: [ if (callState.createdByMe) FloatingActionButton.extended( heroTag: 'go-live', label: callState.isBackstage ? const Text('Go Live') : const Text('Stop Live'), icon: callState.isBackstage ? const Icon( Icons.play_arrow, color: Colors.green, ) : const Icon( Icons.stop, color: Colors.red, ), onPressed: () { if (callState.isBackstage) { widget.audioRoomCall.goLive(); } else { widget.audioRoomCall.stopLive(); } }, ), FloatingActionButton( heroTag: 'microphone', child: _microphoneEnabled ? const Icon(Icons.mic) : const Icon(Icons.mic_off), onPressed: () { if (_microphoneEnabled) { widget.audioRoomCall.setMicrophoneEnabled(enabled: false); setState(() { _microphoneEnabled = false; }); } else { if (!widget.audioRoomCall.hasPermission( CallPermission.sendAudio, )) { widget.audioRoomCall.requestPermissions( [CallPermission.sendAudio], ); setState(() { _waitingForPermission = true; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Permission to speak requested'), ), ); } else { widget.audioRoomCall.setMicrophoneEnabled(enabled: true); setState(() { _microphoneEnabled = true; }); } } }, ), ], ); }); } void dispose() { _callEventsSubscription?.cancel(); super.dispose(); } }
Audio rooms start in backstage mode by default, requiring us to call call.goLive() to make them publicly accessible. We've already implemented this in Step 6, when creating the call.
However, we can also toggle between live and backstage modes using call.goLive() and call.stopLive() respectively.
It's important to note that regular users can only join an audio room when it's in live mode. We only show this button to the audio room owner.
In the code above, you'll notice our microphone toggle implementation includes a permission check with call.hasPermission(). This is crucial since most audio room participants are typically listeners without speaking privileges.
If a user lacks speaking permission, we can request it programmatically using the call.requestPermissions() method.
If the user has requested permission, we show a snackbar to inform them that the permission has been requested.
We also subscribe to the call.callEvents stream to be notified when the user's permission has been granted.
Human checkpoint: the mic button flips between the two icons as you tap it, and the Go Live button flips to Stop Live. Allow microphone access if the device hasn't already asked. Agents: build and launch, then ask the human to grant access and confirm both buttons respond.
Step 9 - Go Live and Join From the Browser
To make this a little more interactive, let's join the audio room from your browser:
If all works as intended, you will see an audio room with two participants:

You can request permission to speak by pressing the hand icon in the browser.
By default, the audio_room call type has backstage mode enabled, which creates a private space where hosts can prepare before making the room public. This is particularly useful for testing audio quality, discussing topics in advance, or coordinating with co-hosts before allowing audience members to join.
In this tutorial, we called call.goLive() immediately after creating the call, which transitions the room from backstage to live mode when you navigate to the audio room screen. This means any users can see and join your room right away.
You can customize this behavior through Stream's dashboard, where you can configure default settings for backstage mode, control who can transition calls between states, and set up other call-specific permissions to match your app's requirements. The call types guide covers what each type turns on.
Using your own credentials from Option 1? The Join Call button above joins the shared tutorial room, not yours. Run the app on a second device 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 participant grid shows two avatars. Keep that browser tab open for the next step.
Step 10 - Handle Permission Requests
By default, the audio_room call type only allows speaker, admin and host roles 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.
Lets wrap the StreamBuilder in a Stack widget and add a PermissionRequests widget to the bottom of the screen.
12345678910111213141516171819202122body: Stack( children: [ StreamBuilder<CallState>( ... ), if (widget.audioRoomCall.state.value.createdByMe) Positioned( bottom: 0, left: 0, right: 0, child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.only(bottom: 80), child: PermissionRequests( audioRoomCall: widget.audioRoomCall, ), ), ), ), ], ),
We only show the PermissionRequests widget to the call owner.
The PermissionRequests widget is a custom widget that takes the audioRoomCall as a parameter
and displays permission requests one at a time to the call owner.
Using this simple UI component, owner of the call can grant or deny permission requests.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697class PermissionRequests extends StatefulWidget { const PermissionRequests({required this.audioRoomCall, super.key}); final Call audioRoomCall; State<PermissionRequests> createState() => _PermissionRequestsState(); } class _PermissionRequestsState extends State<PermissionRequests> { final List<StreamCallPermissionRequestEvent> _permissionRequests = []; void initState() { super.initState(); widget.audioRoomCall.onPermissionRequest = (permissionRequest) { setState(() { _permissionRequests.add(permissionRequest); }); }; } Widget build(BuildContext context) { if (_permissionRequests.isEmpty) { return const SizedBox.shrink(); } final request = _permissionRequests.first; final displayName = request.user.name.isNotEmpty ? request.user.name : request.user.id; final permissions = request.permissions.join(', '); return Padding( padding: const EdgeInsets.all(16), child: Card( elevation: 6, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Column( children: [ ListTile( dense: true, leading: CircleAvatar( radius: 18, child: Text( displayName.isNotEmpty ? displayName[0].toUpperCase() : '?', ), ), title: Text('$displayName requests'), subtitle: Text(permissions), ), Align( alignment: Alignment.centerRight, child: Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( onPressed: () { setState(() { _permissionRequests.removeAt(0); }); }, child: const Text( 'Deny', style: TextStyle(color: Colors.red), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: () async { await widget.audioRoomCall.grantPermissions( userId: request.user.id, permissions: request.permissions.toList(), ); if (mounted) { setState(() { _permissionRequests.removeAt(0); }); } }, child: const Text('Allow'), ), ], ), ), ), ], ), ), ); } }
Checkpoint: press the hand icon in the browser tab from Step 9 and the request card appears on the device. Tap Allow and the browser participant can unmute; tap Deny and the card disappears without granting anything. More on the model behind this in permissions and moderation.
Other built-in features
There are a few more exciting features that you can use to build audio rooms:
- Requesting Permissions: Participants can ask the host for permission to speak, share video etc
- Query Calls: You can query calls to easily show upcoming calls, calls that recently finished etc
- Call Previews: Before you join the call you can observe it and show a preview. IE John, Sarah and 3 others are on this call.
- Reactions & Custom events: Reactions and custom events are supported
- Recording & Broadcasting: You can record your calls, or broadcast them to HLS
- 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 and closed captions: Turn what is said in the room into text, live or after the fact
Verify the whole build
Build to your device and run the full loop:
1flutter run
Tap "Create an Audio Room", allow microphone access, and confirm your own avatar appears with a green border while you talk. Join from a browser tab and watch the grid grow to two. Toggle the mic and the Go Live / Stop Live button. Raise a hand from the browser, approve it on the device, and confirm the browser participant can then speak. Close the room with the AppBar's close button and confirm you land back on the home screen.
Troubleshooting
Invalid version constraintfromflutter pub get- the^latestplaceholders inpubspec.yamlare not real constraints. Replace them with the current version from pub.dev (Step 2).project credentials missingorstream project is not initialized- the CLI has not been initialized here. Rungetstream initin the project directory first.token is invalid/ auth error - the token was minted for a different app, or it expired. Re-mint withgetstream token <user-id>and confirm the API key matches.- Nothing happens when you tap "Create an Audio Room" -
getOrCreate()orjoin()did not complete. Theresult.isSuccesscheck in Step 6 prints the reason withdebugPrint. - 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 - the button only renders when
callState.createdByMeis true. Only the room's creator can go live; regular participants can't. - The browser participant can't speak - that's the
audio_roomdefault. They requestsendAudioand you grant it fromPermissionRequests(Step 10). - Participant count stays at 1 - two sessions signed in as the same user count as one participant. Use a different user id in the second session.
- No audio, or the mic button does nothing - you're likely on an emulator, which has limited audio device support; build to a physical device. If you're on a device, microphone access was denied - check system settings.
- Microphone permission prompt never appears -
NSMicrophoneUsageDescriptionis missing fromInfo.plist, orRECORD_AUDIOis missing fromAndroidManifest.xml(Step 2). - Nobody ever shows as speaking -
isSpeakingonly flips for participants who actually publish audio. A listener with nosendAudiopermission never lights up. The package product 'stream-video-flutter' requires minimum platform version 14.0- the iOS deployment target is still at theflutter createdefault of 13.0. Raise it to 14.0 (Step 2). Recent Flutter versions use Swift Package Manager and generate noPodfile, so the fix lives in the Xcode project, not aPodfile.- iOS build fails to find a pod - only applies if your project still uses CocoaPods (there is an
ios/Podfile). Runpod installfrom theiosfolder, orflutter cleanfollowed byflutter run. On a Swift Package Manager project,flutter cleanand rebuild instead. - iOS device build fails to sign - open
ios/Runner.xcworkspacein Xcode, pick a Team under Signing & Capabilities, and change the bundle identifier if the default is taken.
Recap
Find the complete code for this tutorial on the Flutter Video Tutorials Repository.
Stream Video allows you to quickly build a scalable audio-room experience for your app. Please do let us know if you ran into any issues while running this tutorial. 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 setup a call with
final call = StreamVideo.instance.makeCall(callType: StreamCallType.audioRoom(), id: 'CALL_ID');. - The call type
audio_roomcontrols which features are enabled and how permissions are set up. - The
audio_roomby default enables backstage mode, and only allows admins to join before the call goes live. - When you join a call, realtime communication is setup for audio & video calling with
call.join(). - Data in
call.stateandcall.state.value.callParticipantsmake 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 livestreaming 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
- Video calling - the same
Callobject, with video: video calling tutorial - Livestreaming - broadcast to an unlimited audience: livestreaming tutorial
- Ringing & CallKit - native incoming-call screens and VoIP push: ringing tutorial
- AI voice assistant - connect a realtime voice agent to a room: voice assistant tutorial
- Audio quality - high fidelity audio and noise cancellation
- Room discovery - list upcoming, live and finished rooms with querying calls
- Call and participant state - the full state model: guide
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
In this video app tutorial we built a fully functioning Flutter audio room app with our Flutter SDK component library. We also showed how easy it is to customize the behavior and the style of the Flutter video app components with minimal code changes.
Both the video SDK for Flutter 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, thengetstream skills --universalandgetstream skills stream-flutter --universalfor the Flutter pack (use--claudeinstead for Claude Code). (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); pick any URL-safe call id - Call type:
audio_room- starts in backstage, and onlyhost,adminandspeakerroles may send audio. Regular participants requestsendAudioand a host grants it. Both defaults are editable per call type. - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, calls, and call members - Flutter integration skill: invoke
/stream-flutterin your agent for Flutter setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/flutter/llms.txt - Markdown endpoints: append
.mdto 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

