#if os(macOS) import AudioToolbox import AVFoundation import CoreAudio import Foundation // Spec ยง15.20: which input of a multi-channel microphone the call sends. // // LiveKit's AVAudioEngine module connects the microphone to its mixer in // mono, at the hardware rate, and the conversion from a multi-channel // interface to mono keeps the first input only. On a Scarlett 18i8 with // the microphone on input 2 the call sent silence, and nothing on the // Mac said so (Paul and Tony, 2026-09-25). // // The fix is the input unit's own channel map: every channel it hands the // engine is set to the chosen device input, so whatever the engine does // to make mono of them, mono is that input. The module is told nothing; // its default connection stays in place. /// The device a call's microphone is on, as CoreAudio describes it. public struct InputDeviceChannels: Equatable, Sendable { public var uid: String public var name: String /// Input channels on the device, 1 for an ordinary microphone. public var count: Int /// CoreAudio's name for each input channel, where the driver gives /// one ("Analogue 1"); empty strings where it does not. public var names: [String] public init(uid: String, name: String, count: Int, names: [String] = []) { self.uid = uid self.name = name self.count = count self.names = names } /// "Input 2", or "Input 2 (Analogue 2)" where the channel has a name /// that says more than its number. `index` is 0-based. public func label(_ index: Int) -> String { let base = "Input \(index + 1)" guard index < names.count else { return base } let name = names[index].trimmingCharacters(in: .whitespaces) if name.isEmpty || name == "\(index + 1)" { return base } return "\(base) (\(name))" } /// What the โ“˜ and the readout call it: "Scarlett 18i8 USB, input 1", /// or just the name for a one-channel microphone. public func describe(channel: Int) -> String { count > 1 ? "\(name), input \(channel + 1)" : name } } /// The remembered input for each device, by CoreAudio UID. Input 1 (0) /// is where every device starts, which is what the module always did. public struct InputChannelPreference { static func key(_ uid: String) -> String { "audioInputChannel.\(uid)" } /// The remembered channel, 0-based, clamped to the channels the /// device has now (an interface can be reconfigured with fewer). public static func load(uid: String, channels: Int, _ defaults: UserDefaults) -> Int { let saved = defaults.integer(forKey: key(uid)) return saved >= 0 && saved < max(channels, 1) ? saved : 0 } public static func save(_ channel: Int, uid: String, _ defaults: UserDefaults) { if channel <= 0 { defaults.removeObject(forKey: key(uid)) } else { defaults.set(channel, forKey: key(uid)) } } } /// The pure parts of the channel map, apart from any device. public enum InputChannelMap { /// The input to send from, 0-based, or 0 to leave the unit as the /// module wired it. Only a microphone the person chose by name (not /// the system default) with more than one input, and only an input /// they chose other than the first: 0.3.8 made the map strictly /// opt-in, so a default or ordinary microphone is wired exactly as it /// was in 0.3.3, before there was a map at all. public static func channel(saved: Int, device: String, explicit: String?, inputs: Int) -> Int { guard let explicit, explicit == device, inputs > 1, saved > 0, saved < inputs else { return 0 } return saved } /// The map for an input unit whose client side has `clientChannels` /// channels: every one of them carries device input `channel` /// (0-based), so a mono mixdown of them โ€” or the first of them โ€” is /// that input. Nil means leave the unit alone: input 1 on a unit this /// app has never mapped is exactly what the module does already. /// /// `offset` is where the microphone's channels start on the unit's /// device: 0 for the microphone itself, and past the speaker's own /// inputs on an aggregate that joins the two (AudioEnginePin), where /// input 1 of the microphone is not input 1 of the device. public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool, offset: Int = 0) -> [Int32]? { let width = max(clientChannels, 1) let source = max(channel, 0) + max(offset, 0) if source <= 0 { // Undo an earlier choice on the same unit: straight through. return mappedBefore ? (0.. Int { list.reduce(0) { $0 + Int($1.mNumberChannels) } } } /// CoreAudio, read. Every call here is a property query on the HAL; none /// opens a device or needs microphone permission. enum CoreAudioInput { /// The device for a module device id: its CoreAudio UID, or "default" /// (the module's own name for the system default). static func device(forModuleID id: String?) -> AudioDeviceID? { if let id, id != "default", !id.isEmpty, let device = device(uid: id) { return device } return defaultInput() } static func device(uid: String) -> AudioDeviceID? { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyTranslateUIDToDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var cf = uid as CFString var device = AudioDeviceID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) let status = withUnsafeMutablePointer(to: &cf) { pointer in AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, UInt32(MemoryLayout.size), pointer, &size, &device) } return status == noErr && device != kAudioObjectUnknown ? device : nil } static func defaultInput() -> AudioDeviceID? { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var device = AudioDeviceID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) return status == noErr && device != kAudioObjectUnknown ? device : nil } static func string(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain ) -> String? { var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element) guard AudioObjectHasProperty(device, &address) else { return nil } var value: Unmanaged? var size = UInt32(MemoryLayout?>.size) let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) guard status == noErr, let value else { return nil } return value.takeRetainedValue() as String } static func inputChannelCount(_ device: AudioDeviceID) -> Int { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, mScope: kAudioObjectPropertyScopeInput, mElement: kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 else { return 0 } let raw = UnsafeMutableRawPointer.allocate( byteCount: Int(size), alignment: MemoryLayout.alignment) defer { raw.deallocate() } let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1) guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { return 0 } return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list)) } static func describe(_ device: AudioDeviceID) -> InputDeviceChannels? { guard let uid = string(device, kAudioDevicePropertyDeviceUID) else { return nil } let name = string(device, kAudioObjectPropertyName) ?? "Microphone" let count = inputChannelCount(device) // Names only matter for a picker, which only a multi-channel device // gets; a USB interface's driver usually names its inputs. let names = count > 1 ? (1...count).map { string( device, kAudioObjectPropertyElementName, scope: kAudioObjectPropertyScopeInput, element: AudioObjectPropertyElement($0)) ?? "" } : [] return InputDeviceChannels(uid: uid, name: name, count: count, names: names) } /// The device an input unit is on. static func device(of unit: AudioUnit) -> AudioDeviceID? { var device = AudioDeviceID(kAudioObjectUnknown) var size = UInt32(MemoryLayout.size) let status = AudioUnitGetProperty( unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, &size) return status == noErr && device != kAudioObjectUnknown ? device : nil } /// Channels on the input unit's client side: what the engine is handed. static func clientChannels(of unit: AudioUnit) -> Int { var format = AudioStreamBasicDescription() var size = UInt32(MemoryLayout.size) let status = AudioUnitGetProperty( unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1, &format, &size) return status == noErr ? Int(format.mChannelsPerFrame) : 0 } static func setChannelMap(_ map: [Int32], on unit: AudioUnit) -> OSStatus { var map = map return AudioUnitSetProperty( unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Output, 1, &map, UInt32(MemoryLayout.size * map.count)) } } /// Applies the remembered channel to the engine module's input unit. /// Called from the module's observer on its own thread, when it wires the /// microphone in and again just before it starts the engine (by which /// time the unit's client format is the one it will run with). final class InputChannelMapper: @unchecked Sendable { private let defaults: UserDefaults /// The microphone behind an aggregate the engine is on, and where its /// channels start (AudioEnginePin.inputLayout). Nil for a device that /// is the microphone itself. private let layout: (AudioDeviceID) -> (uid: String, offset: Int)? /// The microphone the person chose, or nil for the system default. private let explicitInput: () -> String? private let lock = NSLock() private var mapped = false /// What was last applied, for the readout: the device and the channel. private(set) var applied: (uid: String, channel: Int)? init( defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }, explicitInput: @escaping () -> String? = { nil } ) { self.defaults = defaults self.layout = layout self.explicitInput = explicitInput } func apply(to node: AVAudioNode?) { guard let node = node as? AVAudioInputNode, let unit = node.audioUnit, let device = CoreAudioInput.device(of: unit), let unitUID = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) else { return } let (uid, offset) = layout(device) ?? (unitUID, 0) let count = CoreAudioInput.device(uid: uid).map(CoreAudioInput.inputChannelCount) ?? 0 let channel = InputChannelMap.channel( saved: InputChannelPreference.load(uid: uid, channels: count, defaults), device: uid, explicit: explicitInput(), inputs: count) lock.lock() defer { lock.unlock() } guard let map = InputChannelMap.map( channel: channel, clientChannels: CoreAudioInput.clientChannels(of: unit), mappedBefore: mapped, offset: offset) else { applied = (uid, 0) return } let status = CoreAudioInput.setChannelMap(map, on: unit) if status == noErr { mapped = channel + offset > 0 applied = (uid, channel) } AudioLog.info( "input channel map \(map.map(String.init).joined(separator: ",")) for \(uid) input \(channel + 1): OSStatus \(status)") } } #endif