Audio Device selection on iOS
How to select an audio device as output on iOS.
Multi-Route
By default, when connecting several audio devices to an iOS device, the device that will actually be selected to output audio is the last one that was detected. That is, unless the AVAudioSession
is set up using the multiRoute
category. When it’s the case, all detected audio devices will be gathered in the aggregate device generated for AVAudioEngine
. Thus, the output format will have a number of channels that is equal to the sum of the channels of each audio device.
For instance, let’s connect a TV with stereo output through HDMI and an audio interface with 4 output channels through USB-A. An adapter with those two ports is connected to an iPad through USB-C. The channels count of the engine’s output node will be 6.
flowchart LR
output0[output 0] --- device1-0[TV 0] -.- device1[TV]
output1[output 1] --- device1-1[TV 1] -.- device1[TV]
output2[output 2] --- device2-0[Audio Interface 0] -.- device2[Audio Interface]
output3[output 3] --- device2-1[Audio Interface 1] -.- device2[Audio Interface]
output4[output 4] --- device2-3[Audio Interface 2] -.- device2[Audio Interface]
output5[output 5] --- device2-4[Audio Interface 3] -.- device2[Audio Interface]
Device Selection
If the user selects a device - regardless of how the UI presents the choice - it’s required to use channel mapping to route the audio stream to the corresponding channels.
To show how this articulates in practice, we will work with a stereo output stream that we want to route to either a stereo TV plugged to a HDMI port or a stereo audio interface plugged to a USB-A port.
Player
The audio logic will lie in a Player
class.
1
2
3
4
final class Player {
let session = AVAudioSession.sharedInstance()
let engine = AVAudioEngine()
}
We already add an AVAudioEngine
instance as well as a reference to the shared audio session.
AudioDevice
To offer device selection we are going to need a device model to work with for convenience so let’s add it.
1
2
3
4
5
6
struct AudioDevice {
let name: String
let port: AVAudioSession.Port
let uid: String
let channelsCount: Int
}
AudioDevice
will map the available output devices, e.g. the TV and the audio interface. The name
is for display purpose, the port
specifies how the device is plugged in (HDMI, USB, Bluetooth…), the uid
is an unique identifier of this device computed from the manufacturer identifier, the serial number as well as other attributes and provided by the shared session. Finally, the channels count corresponds to the available output channels of the device. So this will be 2 for both devices in this example.
Let’s add a property to store the devices in the Player
class.
1
var devices: [AudioDevice] = []
Audio devices are retrieved from the AVAudioSession.currentRoute.outputs
property. This should be called at the initial setup but also every time the AVAudioSession.routeChangeNotification
is triggered.
1
2
3
4
5
6
7
8
9
10
11
12
13
func retrieveAudioDevices() {
devices.removeAll()
for portDescription in session.currentRoute.outputs {
let device = AudioDevice(
name: portDescription.portName,
port: portDescription.portType,
uid: portDescription.uid,
channelsCount: portDescription.channels?.count ?? 0
)
devices.append(device)
}
}
Note that the order to retrieve the audio devices is important. The engine’s output node format follows the order of the
AVAudioSessionPortDescription
inside theoutputs
array.
It’s then possible to use the devices
array in a picker so that the user can select a specific device as output.
A SwiftUI
Picker
could do the job onceAudioDevice
conforms toIdentifiable
but UI is out of the scope of this article.
Once the user selects a device, the Player
class can observe that and call a function to route the audio stream to the proper channels of the engine’s output node.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func setupChanelMappingForAudioDevice(_ selectedDevice: AudioDevice) {
// 1
var channelsStart = 0
for device in devices {
if device == selectedDevice {
break
} else {
channelsStart += device.channelsCount
}
}
// 2
var channelMap = Array(
repeating: -1,
count: Int(engine.outputNode.outputFormat(forBus: 0).channelCount)
)
channelMap[channelsStart] = 0
channelMap[channelsStart + 1] = 1
engine.outputNode.auAudioUnit.channelMap = channelMap as [NSNumber]
}
- To find the channel index of the engine’s output node corresponding to the selected device, we iterate over the
devices
and augment thechannelStart
property by the number of channels of the device if it’s not a match. Once we exit the loop,channelStart
points to the first channel of the selected device in the engine’s output node channels. - We setup an array of
Int
serving as a channel map and assign it to the engine’s output node.
There are other ways to do channel mapping. To learn more about this topic, read Channel Mapping.
After calling setupChanelMappingForAudioDevice(_:)
, the stereo audio stream should be heard in through the selected device.
Of course there are plenty of other scenarios where you might want to route audio differently. You might have a mono stream, have more than two output devices, more than two channels per device, and so on. So you might be interested in reading the article dealing with channel mapping mentioned in the info prompt.
Similarly, device selection comes in many flavors. On iOS, you might prefer to ask the user only for a port type and then find the first device corresponding to this port type. This is more robust if the specific audio device isn’t relevant and the user wants to always play audio through USB for instance.
You can find a basic implementation of Player
and AudioDevice
in the post resources.