Post

Channel Mapping

How to implement channel mapping with AVAudioEngine

Channel mapping is a way of describing how input channels are mapped to output channels. The term “input” here refers to the input of a processing unit. It can be an unit that simply mixes audio like AVAudioMixerNode, or one that is used to retrieve audio from input devices such as microphones, MIDI devices and so on - typically the audio unit used in AVAudioEngine.inputNode.

When to use it

Channel mapping is useful in many situations because it allows to fine-grained how a stream from each input channel can be routed to one, several or no output channels. Here are two examples where channel mapping could be used.

Duplicate audio

Let’s say for instance that you have a microphone plugged in to a device (or you are using the device built-in microphone). The output is a stereo so it has two channels, and the way AVAudioEngine works makes that the input has one channel. By default, channel are mapped with a 1:1 relationship so the audio stream from the input channel (index 0) will be routed to the first output channel (index 0), and the second output channel (index 1) will not receive any audio input. The diagram below summarizes that.

flowchart LR
input0[input 0] --> output0[output 0]
input1[∅] --> output1[output 1]

In this case, you might want to route [input 0] to both [output 0] and [output 1] so that the microphone audio is heard in both speakers:

flowchart LR
input0[input 0] --> output0[output 0]
input0[input 0] --> output1[output 1]

Route stereo to another device

It is quite common to have more than two outputs when playing audio. For instance when an audio interface with four output channels is plugged in, or when using the multiRoute category of AVAudioSession with two stereo devices plugged in. In such a case, being able to send a stereo input stream to the third and fourth channels (respectively [output 2] and [output 3]) might be needed so that the audio stream is heard in the proper device.

flowchart LR
none[∅] --> output0[output 0] -.- device1[1st device]
none[∅] --> output1[output 1] -.- device1[1st device]
input0[input 0] --> output2[output 2] -.- device2[2nd device]
input1[input 1] --> output3[output 3] -.- device2[2nd device]

Using a channel map

The easiest API to operate a channel mapping is the channelMap property of AUAudioUnit, which is an Obj-C wrapper around AudioUnit from CoreAudio. It can be set on some specific audio nodes in the graph of an AVAudioEngine.

From my experience channelMap only works on AVAudioEngine.outputNode and AVAudioPlayerNode.

Setting it on an output node is fairly easy:

1
2
let engine = AVAudioEngine()
engine.outputNode.auAudioUnit.channelMap = [0, 1]

This property describes what input channel should be routed to the output channel at the given index. Output channels are associated to the indexes of the array, and input channels are the values in the array.

Let’s use the second example where input stereo is routed to a second device. The default channel map that is used would be [0, 1, -1, -1].

  • 0 at the index 0 means that [output 0] should receive the audio stream from [input 0].
  • Similarly, [output 1] should receive the audio stream from [input 1].
  • Meanwhile, [output 2] and [output 3] will not receive any audio stream as denoted by the -1 values.

An insightful way to think about it is provided on Apple Developer forums and we’ll use the same kind of diagrams here (this example is the same as the one on the Apple Developer forums page).

1
2
3
[0, 1] // inputs
 |  |
[0, 1, -1, -1] // outputs

Now, to route the stereo input to [output 2] and [output 3] channels, we can simply set the channel map to [-1, -1, 0, 1]. Again, a diagram helps to understand:

1
2
3
4
5
6
[0, 1] // inputs
 |  |
 |  |_______
 |_______   |
         |  |
[-1, -1, 0, 1] // outputs

We can quickly imagine other use cases that the channelMap property cover. To come back to the first example: duplicating a mono audio stream to a stereo one would require to set the channel map to [0, 0]. Also, inverting channels to send [input 0] to [output 1] and [input 1] to [output 0]: channelMap = [1, 0].

The AUAudioUnit.channelMap property is fairly easy to use and provides numerous mapping possibilities. However, it has limitations for more complex configurations. For example, transforming a stereo stream into a mono stream (sending both [input 0] and [input 1] to [output 0]) is not achievable with this API. Such a transformation cannot be represented simply by setting values in an array.

To set up those more complex mappings, we have to rely on a matrix mixer.

Using a matrix mixer

A matrix mixer brings a tremendous amount of flexibility when compared to a channel map. But it is also quite tedious to configure it properly, especially as its setup has to happen at a specific moment.

Also, unlike some audio units like AVAudioUnitTimePitch that are already exposed as AVAudioUnit which inherit from AVAudioNode, the matrix mixer audio unit has still to be instantiated using an AudioComponentDescription and configured through the AudioUnitSetParameter(_:_:_:_:_:_:) C function. Nothing too difficult though, and once functions are written, using it is quite straightforward.

Instantiate

Let’s first start with a function that instantiates a matrix mixer.

1
2
3
4
5
6
7
8
9
10
11
func matrixMixerNode() async throws -> AVAudioUnit {
    let description = AudioComponentDescription(
        componentType: kAudioUnitType_Mixer,
        componentSubType: kAudioUnitSubType_MatrixMixer,
        componentManufacturer: kAudioUnitManufacturer_Apple,
        componentFlags: 0,
        componentFlagsMask: 0
    )

    return try await .instantiate(with: description)
}

Here, a description is provided to the AVAudioUnit.instantiate(with:options) function. The component type indicates that the audio unit we want to instantiate is a mixer, and the sub component type is really where we specify that we want a matrix mixer. Manufacturer is Apple and the flag parameters are usually set to 0.

Setup gains

From what I have gathered from the sources posted at the end of the post, a matrix mixer should have its gains configured. There is a gain for each input channel, each output channel and a global gain. Failing to set a gain will result in silence for the input or output channel, or even silence completely the audio stream for the global gain.

As explained sooner, the matrix mixer needs to be configured with the AudioUnitSetParameter(_:_:_:_:_:_:) function so we are going to write a helper function in this post. Here is what it looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func setAudioUnitValue(
    avAudioUnit: AVAudioUnit,
    for parameterID: AudioUnitParameterID,
    in scope: UInt32,
    element: AudioUnitElement = 0,
    to value: Float
) throws {
    try AudioUnitSetParameter(
        avAudioUnit.audioUnit,
        parameterID,
        scope,
        element,
        value,
        0
    )
    .checkError("Error while calling 'AudioUnitSetParameter'")
}

First we pass the audioUnit value which is a pointer to the underlying audio unit wrapped by AVAudioUnit. Then the parameterID will specify what parameter we actually want to set. The scope is a way for CoreAudio to differentiate input, output or global scope when we set a parameter of the audio unit. We are going to use the three scopes to set all the gains. The element is scope dependent for this function. This is where we will pass the input or output channel index that we want to set the gain to. Finally, the value is the value of the parameter identified by parameterID. So for the gain this is going to be the value of the gain we want to set.

checkError is a function mapping OSStatus to an error when the result code is not 0. You can find its implementation in the post resources.

With that in place, we are ready to set all the gains:

  • the global gain of the audio unit
  • all the input channels gains
  • all the output channels gains

All gains will have a value of 1 but you are free to modify them and experiment depending on your needs. In this post, only the volumes from one input channel to output channels will be set.

Let’s write a function for each type of gains, with comments.

Global gain

1
2
3
4
5
6
7
8
9
func setGlobalGain(on matrixMixerNode: AVAudioUnit) throws {
    try setAudioUnitValue(
        avAudioUnit: matrixMixerNode,
        for: kMatrixMixerParam_Volume,
        in: kAudioUnitScope_Global,
        element: 0xFFFF_FFFF,
        to: 1
    )
}

The global gain is set using the specific value 2^32 - 1 as denoted by 0xFFFF_FFFF, that’s the way this API works. The parameter kMatrixMixerParam_Volume is going to be used every time we set the gain. Here the scope is global as we are setting the global gain.

Input gains

1
2
3
4
5
6
7
8
9
10
11
12
func setInputGains(on matrixMixerNode: AVAudioUnit) throws {
    let inputChannelsCount = matrixMixerNode.inputFormat(forBus: 0).channelCount
    for inputChannelIndex in 0..<inputChannelsCount {
        try setAudioUnitValue(
            avAudioUnit: matrixMixerNode,
            for: kMatrixMixerParam_Volume,
            in: kAudioUnitScope_Input,
            element: inputChannelIndex,
            to: 1
        )
    }
}

Here we set the gain for each input channel using the same kMatrixMixerParam_Volume parameter, but passing the input channel index as the element and specifying the input scope.

Output gains

1
2
3
4
5
6
7
8
9
10
11
12
func setOutputGains(on matrixMixerNode: AVAudioUnit) throws {
    let outputChannelsCount = matrixMixerNode.outputFormat(forBus: 0).channelCount 
    for outputChannelIndex in 0..<outputChannelsCount {
        try setAudioUnitValue(
            avAudioUnit: matrixMixerNode,
            for: kMatrixMixerParam_Volume,
            in: kAudioUnitScope_Output,
            element: outputChannelIndex,
            to: 1
        )
    }
}

This function is the same as the one setting the input gains, except we rather iterate over the output channels and use the output scope.

With those gains setup functions ready, let’s add a function to set them all up.

1
2
3
4
5
func setupMatrixMixerGains(on matrixMixerNode: AVAudioUnit) throws {
    try setGlobalGain(on: matrixMixerNode)
    try setInputGains(on: matrixMixerNode)
    try setOutputGains(on: matrixMixerNode)
}

Those functions take the matrix mixer node as a parameter which is fine for a demo context but you might want to implement a nice wrapper around a matrix mixer to improve its reusability.

We’re almost done! Except one question remains: why on earth is this called a matrix mixer?

Setup cross points

The matrix mixer API offers to set the gain (or volume) of any input channel to any output channel. Hence the “matrix” term since the visualisation is a 2 dimensional array. In one of the simplest form, one input channel is mapped to one output channe with the same index - also know as the identity. With a 4×4 matrix, this is the visualisation.

↓ Inputs \ Outputs →0123
01000
10100
20010
30001

Remember that the values at the cross points are the volumes. So the 1 specify that the audio stream coming from the input channel should be multiplied by 1 when it is forwarded to the output channel. Thus 0 values effectively silences an input channel for an output channel.

Reusing the second example where the stereo input channels where mapped to the third ([output4]) and fourth ([output 3]) channels, the matrix is:

↓ Inputs \ Outputs →0123
00010
10001

It now appears how powerful this API is as any configuration is possible… But wait! What is the code to achieve that? Let’s write the final configuration function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func setInputVolume(
    _ volume: Float,
    on matrixMixerNode: AVAudioUnit,
    forInputChannel inputChannelIndex: AVAudioChannelCount,
    toOutputChannels outputChannelIndexes: Set<AVAudioChannelCount>
) throws {
    let outputChannelsCount = matrixMixerNode.outputFormat(forBus: 0).channelCount
    for outputChannelIndex in 0..<outputChannelsCount {
        let volume = outputChannelIndexes.contains(outputChannelIndex) ? volume : 0

        let crossPoint = (inputChannelIndex << AVAudioChannelCount(16)) | outputChannelIndex
        try setAudioUnitValue(
            avAudioUnit: matrixMixerNode,
            for: kMatrixMixerParam_Volume,
            in: kAudioUnitScope_Global,
            element: crossPoint,
            to: volume
        )
    }
}

This function takes the volume to be set and the matrix mixer to work on. But more interestingly it takes one input channel and a Set of output channels so that the volume value will be set for all positions in the matrix where the input channel crosses an output channel in the set. This is one way to do it of course, and you are free to expose the API you want. That said, for this method, we iterate over all the output channels and set the volumes at each crossing point. If the output channel is in the set, the volume value will be the one provided in the parameters. Otherwise it will be 0 to silence the input channel in the output channel.

To set this volume value, the matrix mixer API requires to pass a number constructed with bitwise and OR operators as such:

1
(inputChannel << 16) | outputChannel

Bitwise and OR operators roles explanation can be found on Swift documentation.

Once the cross point value is ready, the helper function is called once again passing the cross point value as the element and the same volume key.

Finalizing setup

With all those functions implemented, we are ready to use the matrix mixer in our code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1
let inputFormat = engine.inputNode.inputFormat(forBus: 0)
let matrixMixer = try await matrixMixerNode()
engine.attach(matrixMixer)
engine.connect(inputNode, to: matrixMixer, format: inputFormat)
engine.connect(matrixMixer, to: engine.mainMixerNode, format: inputFormat)

try engine.start()

// 2
try setupMatrixMixerGains(matrixMixer)

// 3
for inputChannel in 0..<inputFormat.channelCount {
    setInputVolume(
        1,
        on: matrixMixer,
        forInputChannel: inputChannel,
        toOutputChannels: [0, 1]
    )
}

Here’s a breakdown:

1. The matrix mixer is instantiated and added to the engine’s graph. We use the input node directly here with its input format.

2. The matrix mixer gains are setup.

It is required to call the gains setup after the engine has started. Otherwise a runtime failure will be triggered.

3. Each input channel is mapped to the first two output channels.

Naturally, any set of output channels could be used. For instance to transform a stereo input into a mono output, the output channel could be passed as a single value in the set : [0]. And in the example with four output channels where a stereo inputs should be routed to the last two output channels, the output channels set would be [2, 3].

Wrap up

To wrap things up, you can find the code to setup a matrix mixer in the post resources. If you have any question, feel free to ask them in the Discussion section of the repository with the title of the post inside brackets: [Channel Mapping].

Sources

Channel map

Matrix mixer

This post is licensed under CC BY 4.0 by the author.