← Back to list

SwiftUI/MacOS: Manage/Configure Audio Device (Microphone) Inputs

Why Life if MacOS is so much harder than iOS? Make sure to update Input Node format MANUALLY!

Itsuki · 2026-03-14 06:20 · 4 claps · 11.3 min read
#swiftui #avaudioengine #macos-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🎵 · Music & Audio

SwiftUI/MacOS: Manage/Configure Audio Device (Microphone) Inputs

Why Life if MacOS is so much harder than iOS? Make sure to update Input Node format MANUALLY!

For iOS, To get available audio devices and have the app to use it, all we have to do is to

  1. Get the [sharedInstance](https://developer.apple.com/documentation/avfaudio/avaudiosession/sharedinstance()) of the [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession)
  2. Get [availableInputs](https://developer.apple.com/documentation/avfaudio/avaudiosession/availableinputs) for an array of input ports available for audio routing.
  3. Use it with [setPreferredInput(_:)](https://developer.apple.com/documentation/avfaudio/avaudiosession/setpreferredinput(_:))

Life is simple!

But what if we are targeting MacOS though? [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession) is not available any more!

Let’s check it out!

Get Mic Available

There are couple different approaches here and let’s check those out one by one.

With AVCaptureDevice Discover Session

This is the more modern Swift Approach, using [AVCaptureDevice.DiscoverySession](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession) to get a list of capture devices that match specific search criteria.

To use it, simple! Two steps!

  1. Creates a discovery session with init(deviceTypes:mediaType:position:) with the device types set to [microphone](https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype-swift.struct/microphone) and [external](https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype-swift.struct/external) , media type set to [AVMediaType](https://developer.apple.com/documentation/avfoundation/avmediatype/audio)
  2. Get the device list by inspecting the [devices](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession/devices) property. This will includes a list of [AVCaptureDevice](https://developer.apple.com/documentation/avfoundation/avcapturedevice)
let deviceTypes: [AVCaptureDevice.DeviceType] = [
    .microphone, .external
]

let session = AVCaptureDevice.DiscoverySession(
    deviceTypes: deviceTypes,
    mediaType: .audio,
    position: .unspecified
)

let microphones = session.devices

Now, what are some parameters do we have in this [AVCaptureDevice](https://developer.apple.com/documentation/avfoundation/avcapturedevice) ?

For identifying the device, we have

To check whether the device is currently connected to the system and available for use, we have the [isConnected](https://developer.apple.com/documentation/avfoundation/avcapturedevice/isconnected) flag, and to check whether the device is in a suspended state, we have [isSuspended](https://developer.apple.com/documentation/avfoundation/avcapturedevice/issuspended). In addition, if we want to find out whether if it is currently used by another application, we can inspect the [isInUseByAnotherApplication](https://developer.apple.com/documentation/avfoundation/avcapturedevice/isinusebyanotherapplication) property.

Another really important property here is the [transportType](https://developer.apple.com/documentation/avfoundation/avcapturedevice/transporttype).

For example, if we want to check whether if the device is virtual or aggregated, we can use this property and compare it to the kAudioDeviceTransportTypeAggregate constant or the kAudioDeviceTransportTypeVirtual constant. Why are we not using the [isVirtualDevice](https://developer.apple.com/documentation/avfoundation/avcapturedevice/isvirtualdevice) property instead? It is not available on mac…

NOW! There is also a huge constraint on this approach! (By itself alone)! As we will see in couple seconds.

With Core Audio

Oh yes, we are here for some objective C….

To get a list of audio devices available, we can use the kAudioHardwarePropertyDevices.

How?

  1. Get AudioObjectPropertyAddress
  2. Get the number of devices within it by getting the total data size of the data stored in the property address AudioObjectGetPropertyDataSize dividing by the side of a single device (id)
  3. Get the actual device Id with AudioObjectGetPropertyData
func getAudioDeviceIds() -> [AudioObjectID] {
    // Get the number of devices
    var propertyAddress = AudioObjectPropertyAddress(
        mSelector: kAudioHardwarePropertyDevices,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain
    )

    var dataSize: UInt32 = 0
    var status = AudioObjectGetPropertyDataSize(
        AudioObjectID(kAudioObjectSystemObject),
        &propertyAddress,
        0,
        nil,
        &dataSize
    )
    guard status == noErr else { return [] }

    let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
    var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)

    status = AudioObjectGetPropertyData(
        AudioObjectID(kAudioObjectSystemObject),
        &propertyAddress,
        0,
        nil,
        &dataSize,
        &deviceIDs
    )
    guard status == noErr else { return [] }

    return deviceIDs
}

Now, this is just the device AudioObjectID, ie: UInt32, what if we want those other properties such as the name, the uniqueId, the transport type and etc?

Repeat the same process as above, but instead of kAudioHardwarePropertyDevices , use kAudioDevicePropertyDeviceUID for uniqueId, kAudioDevicePropertyDeviceNameCFString for name, and etc!

private func getUID(deviceID: AudioObjectID) -> String? {
    var uidSize: UInt32 = UInt32(MemoryLayout<CFString?>.size)
    var uid: CFString?

    var uidPropertyAddress = AudioObjectPropertyAddress(
        mSelector: kAudioDevicePropertyDeviceUID,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain
    )
    let status = withUnsafeMutablePointer(
        to: &uid,
        { uid in
            let status = AudioObjectGetPropertyData(
                deviceID,
                &uidPropertyAddress,
                0,
                nil,
                &uidSize,
                uid
            )
            return status
        }
    )

    if status == noErr, let uid = uid {
        return uid as String
    }

    return nil
}

private func getDeviceName(deviceID: AudioObjectID) -> String? {
    var propertyAddress = AudioObjectPropertyAddress(
        // kAudioDevicePropertyDeviceName with name being String instead of CFString will not work
        mSelector: kAudioDevicePropertyDeviceNameCFString,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain
    )

    var propertySize = UInt32(MemoryLayout<CFString>.size)
    var name: CFString? = nil
    let status = withUnsafeMutablePointer(
        to: &name,
        { name in
            let status = AudioObjectGetPropertyData(
                deviceID,
                &propertyAddress,
                0,
                nil,
                &propertySize,
                name
            )
            return status
        }
    )
    if status == noErr, let deviceNameCF = name as String? {
        return deviceNameCF as String
    }

    return nil
}

Now, if we give our getAudioDeviceIds a run and print out the name for all devices, we will realize that it also has speakers included (we don’t when we use [AVCaptureDevice.DiscoverySession](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession))! Make sure to filter those out!

Set Mic To Be the Capture Device

Now, we got our audio devices available, we have the user choosing one they like, and the next thing we might want to do here is to actually capture some audio with the device!

But! As I have mentioned above, we don’t have [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession) , we don’t have [setPreferredInput(_:)](https://developer.apple.com/documentation/avfaudio/avaudiosession/setpreferredinput(_:))!

So! Here is what we are going to use!

[**AVAudioEngine](https://developer.apple.com/documentation/avfaudio/avaudioengine) > [AVAudioInputNode](https://developer.apple.com/documentation/avfaudio/avaudioinputnode) > [AUAudioUnit](https://developer.apple.com/documentation/audiotoolbox/auaudiounit) > [setDeviceID(_:)](https://developer.apple.com/documentation/audiotoolbox/auaudiounit/setdeviceid(_:))**

try audioEngine.inputNode.auAudioUnit.setDeviceID(deviceId)

A long chain!

Now, what is the device Id we will be setting here? The [AUAudioObjectID](https://developer.apple.com/documentation/audiotoolbox/auaudioobjectid) ! This is basically the same object as the AudioObjectID, ie: UInt32, we had above!

Wait! What if we are getting our capture devices with [AVCaptureDevice.DiscoverySession](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession) ? Yes, unfortunately, we don’t have any property that provides us any information on this!

If you like working with Objective C, you can just get all the properties you need, the name, the transport type, or whatever, using Core Audio directly like what we had above, but I don’t!

So, there are couple ways in approaching this.

We can either get all the uniqueIds for all the deviceIds we get using our getAudioDeviceIds function above, and map it to the devices we found in our [AVCaptureDevice.DiscoverySession](https://developer.apple.com/documentation/avfoundation/avcapturedevice/discoverysession), or there is actually another selector kAudioHardwarePropertyTranslateUIDToDevice we can use to get the deviceId from the uniqueId.

func uniqueIdToAudioObjectId(_ uid: String) -> AudioObjectID? {
    var propertyAddress = AudioObjectPropertyAddress(
        mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain
    )
    var cfUID = uid as CFString
    var deviceID: AudioDeviceID = 0
    var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)

    let status = withUnsafeMutablePointer(
        to: &cfUID,
        { cfUID in
            let status = AudioObjectGetPropertyData(
                AudioObjectID(kAudioObjectSystemObject),
                &propertyAddress,
                UInt32(MemoryLayout<CFString?>.size),
                cfUID,
                &dataSize,
                &deviceID
            )
            return status
        }
    )

    if status == noErr {
        return deviceID
    }

    return nil

}

Put It Together

Here is a little code snippet putting together what we had above + testing it out with some audio capturing with AVAudioEngine like what we had in my previous article ***AVAudioEngine With Swift Concurrency!***


import AVFAudio
// @preconcurrency required for sending AVAudioPCMBuffer, AVAudioTime
@preconcurrency import AVFAudio
import AVFoundation
import Accelerate
import Combine
import CoreAudio
import SwiftUI

// Power of a specific channel
//
// average and peak are expressed in decibels full-scale (dBFS)
//
// - min: -160 dB (0.000_000_01)
// - max: 0 dB (1.0)
struct PowerLevel: Identifiable, Hashable {
    let channel: Int
    let average: Float
    let peak: Float

    var id: Int {
        return channel
    }
}

// MARK: - Extensions
extension Float {
    // decibels full-scale (dBFS)
    // The returned value ranges from –160 dBFS, indicating minimum power, to 0 dBFS, indicating maximum power.
    var powerString: String {
        "\(self.formatted(.number.precision(.fractionLength(0)))) dBFS"
    }

    var linearPower: Float {
        pow(10, self / 20)
    }

    func string(precision: Int) -> String {
        "\(self.formatted(.number.precision(.fractionLength(precision))))"
    }
}

extension UInt32 {
    var deviceTransportTypeDescription: String {
        switch self {
        case kAudioDeviceTransportTypeUnknown:
            "unknown"
        case kAudioDeviceTransportTypeBuiltIn:
            "built in"
        case kAudioDeviceTransportTypeAggregate:
            "aggregate"
        case kAudioDeviceTransportTypeVirtual:
            "virtual"
        case kAudioDeviceTransportTypePCI:
            "PCI"
        case kAudioDeviceTransportTypeUSB:
            "USB"
        case kAudioDeviceTransportTypeFireWire:
            "FireWire"
        case kAudioDeviceTransportTypeBluetooth:
            "Bluetooth"
        case kAudioDeviceTransportTypeBluetoothLE:
            "BLE"
        case kAudioDeviceTransportTypeHDMI:
            "HDMI"
        case kAudioDeviceTransportTypeDisplayPort:
            "DisplayPort"
        case kAudioDeviceTransportTypeAirPlay:
            "Airplay"
        case kAudioDeviceTransportTypeAVB:
            "AVB"
        case kAudioDeviceTransportTypeThunderbolt:
            "Thunderbolt"
        case kAudioDeviceTransportTypeContinuityCaptureWired:
            "ContinuityCaptureWired"
        case kAudioDeviceTransportTypeContinuityCaptureWireless:
            "ContinuityCaptureWireless"
        default:
            "unknown"
        }
    }
}

extension AVAudioPCMBuffer {

    static let kMinLevel: Float = 0.000_000_01  // -160 dB
    static let kMaxLevel: Float = 1.0  // 0 dB

    // Calculates the average (rms) and peak level of each channel in the PCM buffer and caches data.
    var powerLevels: [PowerLevel] {
        var powerLevels: [PowerLevel] = []

        let channelCount = Int(self.format.channelCount)
        let length = vDSP_Length(self.frameLength)

        if let floatData = self.floatChannelData {
            for channel in 0..<channelCount {
                powerLevels.append(
                    calculatePowers(
                        data: floatData[channel],
                        strideFrames: self.stride,
                        length: length,
                        channel: channel
                    )
                )
            }
        } else if let int16Data = self.int16ChannelData {
            for channel in 0..<channelCount {
                // Convert the data from int16 to float values before calculating the power values.
                var floatChannelData: [Float] = Array(
                    repeating: Float(0.0),
                    count: Int(self.frameLength)
                )
                vDSP_vflt16(
                    int16Data[channel],
                    self.stride,
                    &floatChannelData,
                    self.stride,
                    length
                )
                var scalar = Float(INT16_MAX)
                vDSP_vsdiv(
                    floatChannelData,
                    self.stride,
                    &scalar,
                    &floatChannelData,
                    self.stride,
                    length
                )

                powerLevels.append(
                    calculatePowers(
                        data: floatChannelData,
                        strideFrames: self.stride,
                        length: length,
                        channel: channel
                    )
                )
            }
        } else if let int32Data = self.int32ChannelData {
            for channel in 0..<channelCount {
                // Convert the data from int32 to float values before calculating the power values.
                var floatChannelData: [Float] = Array(
                    repeating: Float(0.0),
                    count: Int(self.frameLength)
                )
                vDSP_vflt32(
                    int32Data[channel],
                    self.stride,
                    &floatChannelData,
                    self.stride,
                    length
                )
                var scalar = Float(INT32_MAX)
                vDSP_vsdiv(
                    floatChannelData,
                    self.stride,
                    &scalar,
                    &floatChannelData,
                    self.stride,
                    length
                )

                powerLevels.append(
                    calculatePowers(
                        data: floatChannelData,
                        strideFrames: self.stride,
                        length: length,
                        channel: channel
                    )
                )
            }
        }
        return powerLevels
    }

    private func calculatePowers(
        data: UnsafePointer<Float>,
        strideFrames: Int,
        length: vDSP_Length,
        channel: Int
    ) -> PowerLevel {
        var max: Float = 0.0
        vDSP_maxv(data, strideFrames, &max, length)
        if max < Self.kMinLevel {
            max = Self.kMinLevel
        }

        var rms: Float = 0.0
        vDSP_rmsqv(data, strideFrames, &rms, length)
        if rms < Self.kMinLevel {
            rms = Self.kMinLevel
        }

        return PowerLevel(
            channel: channel,
            average: 20.0 * log10(rms),
            peak: 20.0 * log10(max)
        )
    }
}

extension AVAudioTime {
    static var machineTimeSeconds: TimeInterval {
        return Self.seconds(forHostTime: mach_absolute_time())
    }

    var seconds: TimeInterval {
        return if self.isHostTimeValid {
            Self.seconds(forHostTime: self.hostTime)
        } else {
            Double(self.sampleTime) / self.sampleRate
        }
    }
}

extension AVAudioInputNode {

    // When the engine renders to and from an audio device, the AVAudioSession category and the availability of hardware determines whether an app performs input (for example, input hardware isn’t available in tvOS).
    // Check the input node’s input format (specifically, the hardware format) for a nonzero sample rate and channel count to see if input is in an enabled state.
    nonisolated
        var isEnabled: Bool
    {
        let inputFormat = self.inputFormat(forBus: 0)
        if inputFormat.sampleRate.isZero || inputFormat.sampleRate.isNaN {
            return false
        }
        if inputFormat.channelCount == 0 {
            return false
        }
        return true
    }
}

// MARK: - AudioCapturer Main Implementation

//
// `nonisolated` required because
// `installTap(onBus:bufferSize:format:block:)`: https://developer.apple.com/documentation/avfaudio/avaudionode/installtap(onbus:buffersize:format:block:) will crash if called from the main thread
nonisolated class AudioCapturer {

    var onBuffer: ((AVAudioPCMBuffer) -> Void)?

    let format: AVAudioFormat

    private let audioEngine = AVAudioEngine()

    private let bufferSize: UInt32 = 1024

    init() {
        self.format = audioEngine.inputNode.outputFormat(forBus: 0)
    }

    // todo: handle device changes such as using a mic from headset
    // audioEngine.inputNode.auAudioUnit.setDeviceID()
    // or maybe instruct users to manage their audio input through System Settings (Apple menu > System Settings > Sound > Input), as the AVAudioEngine will automatically use the system's default input device.

    func startCapturing(deviceId: AUAudioObjectID?) throws {
        if let deviceId {
            try audioEngine.inputNode.auAudioUnit.setDeviceID(deviceId)
        }

        // self.logInfo("\(#function)")
        try Self.checkRecordingPermission()

        self.audioEngine.reset()

        let inputNode = audioEngine.inputNode

        if !inputNode.isEnabled {
            throw AudioRecordingError.inputNotEnabled
        }

        inputNode.removeTap(onBus: 0)
        inputNode.installTap(
            onBus: 0,
            bufferSize: self.bufferSize,
            format: self.format
        ) { (buffer: AVAudioPCMBuffer, _: AVAudioTime) in
            self.onBuffer?(buffer)
        }

        audioEngine.prepare()
        try audioEngine.start()
    }

    func stopCapturing() {
        audioEngine.stop()
        audioEngine.inputNode.removeTap(onBus: 0)
        self.audioEngine.reset()
    }
}

// MARK: - Static implementations
nonisolated extension AudioCapturer {
    // recording permission is needed when accessing mic
    static func checkRecordingPermission() throws {
        let permission = AVAudioApplication.shared.recordPermission
        switch permission {

        case .undetermined:
            throw AudioRecordingError.unknownPermission

        case .denied:
            throw AudioRecordingError.permissionDenied

        case .granted:
            return

        @unknown default:
            throw AudioRecordingError.unknownPermission
        }
    }

    static func requestRecordPermission() async {
        // not throwing here because this is intended to be called to prompt for permission instead of showing error
        let _ = await AVAudioApplication.requestRecordPermission()
    }
}

enum AudioRecordingError: Error, LocalizedError {

    case permissionDenied
    case unknownPermission
    case inputNotEnabled

    var errorDescription: String? {
        switch self {

        case .permissionDenied:
            "Recording Permission Denied."
        case .unknownPermission:
            "Unknown Recording Permission."

        // When the engine renders to and from an audio device, the AVAudioSession category and the availability of hardware determines whether an app performs input (for example, input hardware isn't available in tvOS).
        // Check the input node's input format (specifically, the hardware format) for a nonzero sample rate and channel count to see if input is in an enabled state.
        case .inputNotEnabled:
            "Audio Input is not available to use."

        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .permissionDenied, .unknownPermission:
            "Microphone access required. Please enable in System Settings"
        default:
            nil
        }
    }
}

// MARK: - AudioDeviceManager

@Observable
class AudioDeviceManager {

    var selectedDevice: AVCaptureDevice? = .default(
        .microphone,
        for: .audio,
        position: .unspecified
    )

    private(set) var devicesAvailable: [(AudioObjectID, AVCaptureDevice)] = []

    private var timerCancellable: AnyCancellable?
    private let timer = Timer.publish(every: 0.2, on: .main, in: .common)

    init() {
        self.devicesAvailable = self.getAudioCaptureDevices()
        self.timerCancellable = self.timer.autoconnect().sink(receiveValue: {
            [weak self] _ in
            self?.devicesAvailable = (self?.getAudioCaptureDevices() ?? [])
                .sorted(by: { first, second in
                    first.0 < second.0
                })
        })
    }

    deinit {
        self.timerCancellable?.cancel()
    }

    func getAudioCaptureDevices() -> [(AudioObjectID, AVCaptureDevice)] {
        let deviceTypes: [AVCaptureDevice.DeviceType] = [
            .microphone, .external,
        ]

        let session = AVCaptureDevice.DiscoverySession(
            deviceTypes: deviceTypes,
            mediaType: .audio,
            position: .unspecified
        )

        let microphones = session.devices

        var dic: [(AudioObjectID, AVCaptureDevice)] = []

        for microphone in microphones {
            guard
                let deviceId = self.uniqueIdToAudioObjectId(microphone.uniqueID)
            else {
                continue
            }
            dic.append((deviceId, microphone))

        }
        return dic
    }

    func uniqueIdToAudioObjectId(_ uid: String) -> AudioObjectID? {
        var propertyAddress = AudioObjectPropertyAddress(
            mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
            mScope: kAudioObjectPropertyScopeGlobal,
            mElement: kAudioObjectPropertyElementMain
        )
        var cfUID = uid as CFString
        var deviceID: AudioDeviceID = 0
        var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)

        let status = withUnsafeMutablePointer(
            to: &cfUID,
            { cfUID in
                let status = AudioObjectGetPropertyData(
                    AudioObjectID(kAudioObjectSystemObject),
                    &propertyAddress,
                    UInt32(MemoryLayout<CFString?>.size),
                    cfUID,
                    &dataSize,
                    &deviceID
                )
                return status
            }
        )

        if status == noErr {
            return deviceID
        }

        return nil

    }
}

// MARK: - try it out view

struct AudioDeviceManagementView: View {

    @State private var audioDeviceManager = AudioDeviceManager()
    private let audioCapturer = AudioCapturer()
    @State private var powerLevels: [PowerLevel] = []
    @State private var isRecording: Bool = false
    var body: some View {
        ScrollView {
            VStack(
                alignment: .leading,
                spacing: 24,
                content: {
                    Text("My Mic Input Source!")
                        .font(.title2)
                        .fontWeight(.bold)

                    if self.audioDeviceManager.devicesAvailable.isEmpty {
                        Text("No Mic Source available!")
                    }

                    ForEach(self.audioDeviceManager.devicesAvailable, id: \.0) {
                        (deviceId, captureDevice) in
                        inputSourceView(id: deviceId, device: captureDevice)
                    }

                    if audioDeviceManager.selectedDevice != nil {
                        VStack(
                            alignment: .leading,
                            content: {
                                Text("Record To Try")

                                HStack(spacing: 16) {
                                    Button(
                                        action: {
                                            self.isRecording.toggle()
                                        },
                                        label: {
                                            Text(isRecording ? "Stop" : "Start")
                                        }
                                    )

                                    ForEach(powerLevels, id: \.self) { metric in
                                        let total =
                                            AVAudioPCMBuffer.kMaxLevel
                                            - AVAudioPCMBuffer.kMinLevel

                                        let linearAverage = min(
                                            total,
                                            metric.average.linearPower
                                        )
                                        let linearPeak = min(
                                            total,
                                            metric.peak.linearPower
                                        )

                                        VStack {

                                            Text(
                                                String(
                                                    "Channel: \(metric.channel)"
                                                )
                                            )
                                            .font(.subheadline)
                                            .fontWeight(.semibold)
                                            .frame(
                                                maxWidth: .infinity,
                                                alignment: .leading
                                            )

                                            ProgressView(
                                                value: linearAverage,
                                                total: total,
                                                label: {
                                                    Text(
                                                        "Average Power: \(metric.average.powerString)"
                                                    )
                                                    .font(.subheadline)
                                                    .foregroundStyle(.secondary)
                                                }
                                            )

                                            ProgressView(
                                                value: linearPeak,
                                                total: total,
                                                label: {
                                                    Text(
                                                        "Peak Power: \(metric.peak.powerString)"
                                                    )
                                                    .font(.subheadline)
                                                    .foregroundStyle(.secondary)
                                                }
                                            )

                                        }

                                    }

                                }
                                .frame(maxWidth: .infinity, alignment: .leading)

                            }
                        )
                    }
                }
            )
            .scrollTargetLayout()
            .padding()
            .padding(.horizontal, 36)
            .onChange(
                of: isRecording,
                initial: true,
                {
                    audioCapturer.onBuffer = { buffer in
                        self.powerLevels = buffer.powerLevels
                    }

                    if isRecording {
                        do {
                            let device = self.audioDeviceManager
                                .devicesAvailable.first(where: {
                                    $0.1
                                        == self.audioDeviceManager
                                        .selectedDevice
                                })
                            try self.audioCapturer.startCapturing(
                                deviceId: device?.0
                            )
                        } catch (let error) {
                            print(error)
                        }
                    } else {
                        self.audioCapturer.stopCapturing()
                    }
                }
            )

        }
        .frame(width: 640, height: 360)
    }

    @ViewBuilder
    private func inputSourceView(id: AudioObjectID, device: AVCaptureDevice)
        -> some View
    {
        // since we are using inputSource.id for ForEach.id
        // The view will not be updated even if source.isSelected changes
        // Therefore, we are comparing it withe the self.manager.selectedSource instead
        let isSelected = device == self.audioDeviceManager.selectedDevice

        HStack {
            VStack(
                alignment: .leading,
                spacing: 8,
                content: {
                    Text("AudioObjectID: \(id)")
                    Text("UniqueId: \(device.uniqueID)")
                    Text("Name: \(device.localizedName)")
                    Text(
                        "Transport Type: \(UInt32(device.transportType).deviceTransportTypeDescription)"
                    )
                }
            )

            Spacer()

            if !isSelected {
                Button(
                    action: {
                        self.audioDeviceManager.selectedDevice = device
                    },
                    label: {
                        Text("Select")
                    }
                )
                .buttonStyle(.borderedProminent)
            }

        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 16)
                .fill(.gray.opacity(0.8))
                .stroke(.link, style: .init(lineWidth: isSelected ? 2.0 : 0.0))
                .shadow(radius: 4)
                .scaleEffect(isSelected ? 1.05 : 1.0)
        )

    }

}

Add in the Privacy — Microphone Usage Description key to info.plist and let’s try it out!

By the way, I am simply polling here for simplification and it is just a view where I need the mic connection updates, but if you want to monitor the changes to the devices in together with other audio engine configuration changes, you can register to the [AVAudioEngineConfigurationChange](https://developer.apple.com/documentation/foundation/nsnotification/name-swift.struct/avaudioengineconfigurationchange) instead.

IMPORTANT Additional Note

After setDeviceID, the input node’s outputFormat(forBus:) WILL NOT be updated automatically! That is outputFormat(forBus:) still returns the stale format because the audio unit hasn’t renegotiated its stream description yet.

When can that be a problem?

For example, we try to install tap using this format, we crash! (I meant our app!) Of course, in the case where the format for the new device is not the same as the old one!

// if the new device format != old format, CRASH!
try audioEngine.inputNode.auAudioUnit.setDeviceID(AUAudioObjectID(deviceId))
self.format = audioEngine.inputNode.outputFormat(forBus: 0)
inputNode.installTap(
    onBus: 0,
    bufferSize: self.bufferSize,
    format: self.format
) { (buffer: AVAudioPCMBuffer, _: AVAudioTime) in
    //. ..
}

To solve this problem, there are couple solutions.

One! Get the device format with CoreAudio.

static func deviceFormat(for deviceId: AudioDeviceID) -> AVAudioFormat? {
    var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
    var asbd = AudioStreamBasicDescription()
    var address = AudioObjectPropertyAddress(
        mSelector: kAudioDevicePropertyStreamFormat,
        mScope: kAudioDevicePropertyScopeInput,
        mElement: kAudioObjectPropertyElementMain
    )
    let status = AudioObjectGetPropertyData(deviceId, &address, 0, nil, &size, &asbd)
    guard status == noErr else { return nil }
    return AVAudioFormat(streamDescription: &asbd)
}

Two! Pass in nil to install Tap.

Three!

Create a new AVAudioEngine before making the call to setDeviceId! This will ensure that the output format of the input node is up to date.

Thank you for reading!

That’s it for this article!

Why Apple is making everything so hard (or at least tedious) for Mac?

Anyway!

Happy choosing devices!


메타데이터
post_id
0a8f3af39cb4
slug
swiftui-macos-manage-configure-audio-device-microphone-inputs-0a8f3af39cb4
url
https://medium.com/@itsuki.enjoy/swiftui-macos-manage-configure-audio-device-microphone-inputs-0a8f3af39cb4
canonical_url
https://medium.com/@itsuki.enjoy/swiftui-macos-manage-configure-audio-device-microphone-inputs-0a8f3af39cb4
author_url
https://medium.com/@itsuki.enjoy
status
ok
fetched_at
2026-06-09 15:37:30