iOS Audio Rooms Tutorial

Build drop-in audio rooms in Swift with speaker and listener roles, raise hand, and moderation. AVAudioSession routing and CallKit make the room behave like a native iOS call, in the foreground or the background.
iOS audio room built with VideoSDK in SwiftUI showing speakers on stage and listener controls

Trusted by 300+ global business & tech leaders

Build an Audio-Only Room on iOS, Step by Step

Learn how to build an audio-only room on iOS using the VideoSDK iOS SDK. This tutorial covers meeting setup, speaker and listener modes, raise-hand requests, and mute, moderation, and AVAudioSession routing.


Prerequisites

  • iOS 11.0+
  • Xcode 12.0+
  • Swift 5.0+

App Architecture

This App will contain two screen :

  1. Join Screen : This screen allows user to either create meeting or join predefined meeting.

  2. Meeting Screen : This screen basically contain local and remote participant view and some meeting controls such as Enable / Disable Mic and Leave meeting.

Getting Started With the Code!

Step 1: Create App

Step 1: Create a new application by selecting Create a new Xcode project

Step 2: Choose App then click Next

Xcode template chooser with the App template selected for the VideoSDK iOS audio calling quickstart

Step 3: Add Product Name and Save the project.

Xcode new project sheet showing the Product Name field for the VideoSDK iOS audio calling quickstart

Step 2: VideoSDK Installation

To install VideoSDK, you must initialise the pod on the project by running the following command.

Terminal
1pod init

It will create the Podfile in your project folder, open that file and add the dependency for the VideoSDK like below:

Podfile
1pod 'VideoSDKRTC', :git => 'https://github.com/videosdk-live/videosdk-rtc-ios-sdk.git'

Podfile in Xcode with the VideoSDKRTC pod dependency added

then run the below code to install the pod:

Terminal
1pod install

then declare the permission in Info.plist :

Info.plist
1<key>NSMicrophoneUsageDescription</key>
2<string>Microphone permission description</string>

Note: An audio only app publishes the microphone alone, so NSCameraUsageDescription is not required. Leaving it out means iOS never prompts the user for camera access.

Step 3: Project Structure

Project Structure
1iOSQuickStartDemo
2   ├── Models
3        ├── RoomStruct.swift
4        └── MeetingData.swift
5   ├── ViewControllers
6        ├── StartMeetingViewController.swift
7        └── MeetingViewController.swift
8   ├── AppDelegate.swift // Default
9   ├── SceneDelegate.swift // Default
10   └── APIService
11           └── APIService.swift
12   ├── Main.storyboard // Default
13   ├── LaunchScreen.storyboard // Default
14   └── Info.plist // Default
15 Pods
16     └── Podfile

Main.storyboard Design

Wire the outlets and actions used in Steps 7 and 8 to the views in this storyboard. The code will not run until each @IBOutlet and @IBAction is connected.

Step 4: Create Models

Create swift file for MeetingData and RoomStruct class model for setting data in object pattern.

MeetingData.swift
1import Foundation
2
3struct MeetingData {
4    let token: String
5    let name: String
6    let meetingId: String
7    let micEnabled: Bool
8}
RoomStruct.swift
1import Foundation
2
3struct RoomsStruct: Codable {
4    let createdAt, updatedAt, roomID: String?
5    let links: Links?
6    let id: String?
7
8    enum CodingKeys: String, CodingKey {
9        case createdAt, updatedAt
10        case roomID = "roomId"
11        case links, id
12    }
13}
14
15// MARK: - Links
16struct Links: Codable {
17    let getRoom, getSession: String?
18
19    enum CodingKeys: String, CodingKey {
20        case getRoom = "get_room"
21        case getSession = "get_session"
22    }
23}

MeetingData carries the token, participant name, meeting ID, and the initial mic state from the join screen to the meeting screen. There is no camera flag because this app never publishes video. RoomsStruct decodes the response from the VideoSDK Rooms API.

Step 5: Get Started with APIClient

Before jumping to anything else, we have to write API to generate unique meetingId. You will require Auth token, you can generate it using either using videosdk-server-api-example or generate it from the Video SDK Dashboard for developer.

APIService.swift
1import Foundation
2
3let TOKEN_STRING: String = "<AUTH_TOKEN>";
4
5class APIService {
6
7    class func createMeeting(token: String, completion: @escaping (Result<String, Error>) -> Void) {
8        let url = URL(string: "https://api.videosdk.live/v2/rooms")!
9
10        var request = URLRequest(url: url)
11        request.httpMethod = "POST"
12        request.addValue(TOKEN_STRING, forHTTPHeaderField: "authorization")
13
14        URLSession.shared.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) in
15            DispatchQueue.main.async {
16                if let data = data, let utf8Text = String(data: data, encoding: .utf8)
17                {
18                    do{
19                        let dataArray = try JSONDecoder().decode(RoomsStruct.self,from: data)
20                        completion(.success(dataArray.roomID ?? ""))
21                    } catch {
22                        print("Error while creating a meeting: \(error)")
23                        completion(.failure(error))
24                    }
25                }
26            }
27        }).resume()
28    }
29}

Step 6: Implement Join Screen

Join screen will work as medium to either schedule new meeting or to join existing meeting.

StartMeetingViewController.swift
1import Foundation
2import UIKit
3
4class StartMeetingViewController: UIViewController, UITextFieldDelegate {
5
6    private var serverToken = ""
7
8    /// MARK: outlet for create meeting button
9    @IBOutlet weak var btnCreateMeeting: UIButton!
10
11    /// MARK: outlet for join meeting button
12    @IBOutlet weak var btnJoinMeeting: UIButton!
13
14    /// MARK: outlet for meetingId textfield
15    @IBOutlet weak var txtMeetingId: UITextField!
16
17    /// MARK: Initialize the private variable with TOKEN_STRING &
18    /// setting the meeting id in the textfield
19    override func viewDidLoad() {
20        txtMeetingId.delegate = self
21        serverToken = TOKEN_STRING
22        txtMeetingId.text = "PROVIDE-STATIC-MEETING-ID"
23    }
24
25    /// MARK: method for joining meeting through seague named as "StartMeeting"
26    /// after validating the serverToken in not empty
27    func joinMeeting() {
28        txtMeetingId.resignFirstResponder()
29
30        if !serverToken.isEmpty {
31            DispatchQueue.main.async {
32                self.dismiss(animated: true) {
33                    self.performSegue(withIdentifier: "StartMeeting", sender: nil)
34                }
35            }
36        } else {
37            print("Please provide auth token to start the meeting.")
38        }
39    }
40
41    /// MARK: outlet for create meeting button tap event
42    @IBAction func btnCreateMeetingTapped(_ sender: Any) {
43        print("show loader while meeting gets connected with server")
44        joinRoom()
45    }
46
47    /// MARK: outlet for join meeting button tap event
48    @IBAction func btnJoinMeetingTapped(_ sender: Any) {
49        if((txtMeetingId.text ?? "").isEmpty){
50
51            print("Please provide meeting id to start the meeting.")
52            txtMeetingId.resignFirstResponder()
53        } else {
54            joinMeeting()
55        }
56    }
57
58    // MARK: - method for creating room api call and getting meetingId for joining meeting
59
60    func joinRoom() {
61
62        APIService.createMeeting(token: self.serverToken) { result in
63            if case .success(let meetingId) = result {
64                DispatchQueue.main.async {
65                    self.txtMeetingId.text = meetingId
66                    self.joinMeeting()
67                }
68            }
69        }
70    }
71
72    /// MARK: preparing to animate to meetingViewController screen
73    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
74
75        guard let navigation = segue.destination as? UINavigationController,
76              let meetingViewController = navigation.topViewController as? MeetingViewController else {
77              return
78          }
79
80        meetingViewController.meetingData = MeetingData(
81            token: serverToken,
82            name: txtMeetingId.text ?? "Guest",
83            meetingId: txtMeetingId.text ?? "",
84            micEnabled: true
85        )
86    }
87}

Output

ios_quickstart_join_screen.png

Step 7: Initialize and Join Meeting

Using the provided token and meetingId, we will configure and initialise the meeting in viewDidLoad().

Then, we'll add @IBOutlet for lblLocalParticipantName and lblRemoteParticipantName, which display the local and remote participant in their container views. An audio call has no video track to render, so the containers hold name labels instead of RTCMTLVideoView.

MeetingViewController.swift
1import UIKit
2import VideoSDKRTC
3import AVFoundation
4
5class MeetingViewController: UIViewController {
6
7    // MARK: - Properties
8
9    // outlet for local participant container view
10    @IBOutlet weak var localParticipantViewContainer: UIView!
11
12    // outlet for label for meeting Id
13    @IBOutlet weak var lblMeetingId: UILabel!
14
15    // outlet for local participant name label
16    @IBOutlet weak var lblLocalParticipantName: UILabel!
17
18    // outlet for remote participant container view
19    @IBOutlet weak var remoteParticipantViewContainer: UIView!
20
21    // outlet for remote participant name label
22    @IBOutlet weak var lblRemoteParticipantName: UILabel!
23
24    /// Meeting data - required to start
25    var meetingData: MeetingData!
26
27    /// current meeting reference
28    private var meeting: Meeting?
29
30    // MARK: - audio participants including self to show in UI
31    private var participants: [Participant] = []
32
33    // MARK: - Lifecycle Events
34
35    override func viewDidLoad() {
36        super.viewDidLoad()
37        // configure the VideoSDK with token
38        VideoSDK.config(token: meetingData.token)
39
40        // init meeting
41        initializeMeeting()
42
43        // set meeting id in button text
44        lblMeetingId.text = "Meeting Id: \(meetingData.meetingId)"
45    }
46
47    override func viewWillAppear(_ animated: Bool) {
48        super.viewWillAppear(animated)
49        navigationController?.navigationBar.isHidden = true
50    }
51
52    override func viewWillDisappear(_ animated: Bool) {
53        super.viewWillDisappear(animated)
54        navigationController?.navigationBar.isHidden = false
55        NotificationCenter.default.removeObserver(self)
56    }
57
58    // MARK: - Meeting
59
60    private func initializeMeeting() {
61
62        // Initialize the VideoSDK
63        meeting = VideoSDK.initMeeting(
64            meetingId: meetingData.meetingId,
65            participantName: meetingData.name,
66            micEnabled: meetingData.micEnabled,
67            webcamEnabled: false // audio only call, so the camera stays off
68        )
69
70        // Adding the listener to meeting
71        meeting?.addEventListener(self)
72
73        // joining the meeting
74        meeting?.join()
75    }
76}

micEnabled is read from the MeetingData object passed in by the join screen, and webcamEnabled is always false, so the participant publishes the microphone alone as soon as join() succeeds.

Step 8: Implement Controls

After initialising the meeting in previous step. We will now add @IBOutlet for btnLeave and btnToggleMic which can controls the media in meeting.

In the block below, ... marks the code you already wrote in Step 7. Add these members inside the same MeetingViewController class.

MeetingViewController.swift
1class MeetingViewController: UIViewController {
2
3    ...
4
5    // outlet for leave button
6    @IBOutlet weak var btnLeave: UIButton!
7
8    // outlet for toggle audio button
9    @IBOutlet weak var btnToggleMic: UIButton!
10
11    // bool for mic
12    var micEnabled = true
13
14    // outlet for leave button click event
15    @IBAction func btnLeaveTapped(_ sender: Any) {
16        DispatchQueue.main.async {
17            self.meeting?.leave()
18            self.dismiss(animated: true)
19        }
20    }
21
22    // outlet for toggle mic button click event
23    @IBAction func btnToggleMicTapped(_ sender: Any) {
24        if micEnabled {
25            micEnabled = !micEnabled // false
26            self.meeting?.muteMic()
27        } else {
28            micEnabled = !micEnabled // true
29            self.meeting?.unmuteMic()
30        }
31    }
32
33    ...
34
35}

Output

Step 9: Implementing MeetingEventListener

In this step, we'll create an extension for the MeetingViewController that implements the MeetingEventListener, which implements the onMeetingJoined, onMeetingLeft, onParticipantJoined, onParticipantLeft, onParticipantChanged, onSpeakerChanged, etc. methods.

Add this extension at file scope in MeetingViewController.swift, below the closing brace of the class.

MeetingViewController.swift
1extension MeetingViewController: MeetingEventListener {
2
3    /// Meeting started
4    func onMeetingJoined() {
5
6        // handle local participant on start
7        guard let localParticipant = self.meeting?.localParticipant else { return }
8        // add to list
9        participants.append(localParticipant)
10
11        // add event listener
12        localParticipant.addEventListener(self)
13
14        if(localParticipant.isLocal){
15            self.lblLocalParticipantName.text = localParticipant.displayName
16            self.localParticipantViewContainer.isHidden = false
17        } else {
18            self.lblRemoteParticipantName.text = localParticipant.displayName
19            self.remoteParticipantViewContainer.isHidden = false
20        }
21    }
22
23    /// Meeting ended
24    func onMeetingLeft() {
25        // remove listeners
26        meeting?.localParticipant.removeEventListener(self)
27        meeting?.removeEventListener(self)
28    }
29
30    /// A new participant joined
31    func onParticipantJoined(_ participant: Participant) {
32        participants.append(participant)
33
34        // add listener
35        participant.addEventListener(self)
36
37        if(participant.isLocal){
38            self.lblLocalParticipantName.text = participant.displayName
39            self.localParticipantViewContainer.isHidden = false
40        } else {
41            self.lblRemoteParticipantName.text = participant.displayName
42            self.remoteParticipantViewContainer.isHidden = false
43        }
44    }
45
46    /// A participant left from the meeting
47    /// - Parameter participant: participant object
48    func onParticipantLeft(_ participant: Participant) {
49        participant.removeEventListener(self)
50        guard let index = self.participants.firstIndex(where: { $0.id == participant.id }) else {
51            return
52        }
53        // remove participant from list
54        participants.remove(at: index)
55        // hide from ui
56        UIView.animate(withDuration: 0.5){
57            if(!participant.isLocal){
58                self.remoteParticipantViewContainer.isHidden = true
59            }
60        }
61    }
62
63    /// Called when speaker is changed
64    /// - Parameter participantId: participant id of the speaker, nil when no one is speaking.
65    func onSpeakerChanged(participantId: String?) {
66
67        // show indication for active speaker
68        if let participant = participants.first(where: { $0.id == participantId }) {
69            self.showActiveSpeakerIndicator(participant.isLocal ? localParticipantViewContainer : remoteParticipantViewContainer, true)
70        }
71
72        // hide indication for others participants
73        let otherParticipants = participants.filter { $0.id != participantId }
74        for participant in otherParticipants {
75            if participants.count > 1 && participant.isLocal {
76                showActiveSpeakerIndicator(localParticipantViewContainer, false)
77            } else {
78                showActiveSpeakerIndicator(remoteParticipantViewContainer, false)
79            }
80        }
81    }
82
83    func showActiveSpeakerIndicator(_ view: UIView, _ show: Bool) {
84        view.layer.borderWidth = 4.0
85        view.layer.borderColor = show ? UIColor.blue.cgColor : UIColor.clear.cgColor
86    }
87
88}

onSpeakerChanged matters more in an audio call than in a video one: with no camera feed on screen, the blue border is the only cue showing who is talking.

Step 10: Implementing ParticipantEventListener

In this stage, we'll add an extension for the MeetingViewController that implements the ParticipantEventListener, which implements the onStreamEnabled and onStreamDisabled methods for the audio of MediaStreams enabled or disabled.

The function updateUI is frequently used to control or modify the user interface (enable / disable mic) in accordance with MediaStream state.

MeetingViewController.swift
1extension MeetingViewController: ParticipantEventListener {
2
3    /// Participant has enabled mic
4    /// - Parameters:
5    ///   - stream: enabled stream object
6    ///   - participant: participant object
7    func onStreamEnabled(_ stream: MediaStream, forParticipant participant: Participant) {
8        updateUI(participant: participant, forStream: stream, enabled: true)
9    }
10
11    /// Participant has disabled mic
12    /// - Parameters:
13    ///   - stream: disabled stream object
14    ///   - participant: participant object
15    func onStreamDisabled(_ stream: MediaStream, forParticipant participant: Participant) {
16        updateUI(participant: participant, forStream: stream, enabled: false)
17    }
18}
19
20private extension MeetingViewController {
21
22    func updateUI(participant: Participant, forStream stream: MediaStream, enabled: Bool) { // true
23        switch stream.kind {
24        case .state(value: .audio):
25            DispatchQueue.main.async {
26                if participant.isLocal {
27                    self.localParticipantViewContainer.isHidden = false
28                    self.lblLocalParticipantName.text = participant.displayName
29                    self.localParticipantViewContainer.layer.borderWidth = 4.0
30                    self.localParticipantViewContainer.layer.borderColor = enabled ? UIColor.clear.cgColor : UIColor.red.cgColor
31                } else {
32                    self.remoteParticipantViewContainer.isHidden = false
33                    self.lblRemoteParticipantName.text = participant.displayName
34                    self.remoteParticipantViewContainer.layer.borderWidth = 4.0
35                    self.remoteParticipantViewContainer.layer.borderColor = enabled ? UIColor.clear.cgColor : UIColor.red.cgColor
36                }
37            }
38        default:
39            break
40        }
41    }
42}

Output

Final Output

Build and run the app from Xcode, and allow microphone access when iOS asks for permission.

To test the call:

  1. Create a meeting from the Join Screen on the first device.
  2. Copy the meeting ID shown in lblMeetingId on the Meeting Screen.
  3. Run the app on a second device.
  4. Enter the same meeting ID on the Join Screen.
  5. Join the meeting.
  6. Allow microphone permission on both devices.

Your completed iOS app can now:

  • Create a new VideoSDK room
  • Join an existing room by meeting ID
  • Publish microphone media only, with no camera access requested
  • Show the local and remote participant by name
  • Show an active speaker indicator
  • Show a red border when a participant's microphone is disabled
  • Toggle the local microphone
  • Leave the meeting and dismiss the meeting screen

Tip: Stuck anywhere? Check out this example code on GitHub. After the basic call works, explore recording, participant events, and other iOS SDK features in the VideoSDK documentation.

Get started for free today

Grab your API keys and start building with free $20 included in your account. Need enterprise-grade security or custom workloads? Talk to an expert!

Book a demo