| 1 | #if os(macOS) |
| 2 | import AudioToolbox |
| 3 | import AVFoundation |
| 4 | import CoreAudio |
| 5 | import Foundation |
| 6 | |
| 7 | // Spec §15.20: which input of a multi-channel microphone the call sends. |
| 8 | // |
| 9 | // LiveKit's AVAudioEngine module connects the microphone to its mixer in |
| 10 | // mono, at the hardware rate, and the conversion from a multi-channel |
| 11 | // interface to mono keeps the first input only. On a Scarlett 18i8 with |
| 12 | // the microphone on input 2 the call sent silence, and nothing on the |
| 13 | // Mac said so (Paul and Tony, 2026-09-25). |
| 14 | // |
| 15 | // The fix is the input unit's own channel map: every channel it hands the |
| 16 | // engine is set to the chosen device input, so whatever the engine does |
| 17 | // to make mono of them, mono is that input. The module is told nothing; |
| 18 | // its default connection stays in place. |
| 19 | |
| 20 | /// The device a call's microphone is on, as CoreAudio describes it. |
| 21 | public struct InputDeviceChannels: Equatable, Sendable { |
| 22 | public var uid: String |
| 23 | public var name: String |
| 24 | /// Input channels on the device, 1 for an ordinary microphone. |
| 25 | public var count: Int |
| 26 | /// CoreAudio's name for each input channel, where the driver gives |
| 27 | /// one ("Analogue 1"); empty strings where it does not. |
| 28 | public var names: [String] |
| 29 | |
| 30 | public init(uid: String, name: String, count: Int, names: [String] = []) { |
| 31 | self.uid = uid |
| 32 | self.name = name |
| 33 | self.count = count |
| 34 | self.names = names |
| 35 | } |
| 36 | |
| 37 | /// "Input 2", or "Input 2 (Analogue 2)" where the channel has a name |
| 38 | /// that says more than its number. `index` is 0-based. |
| 39 | public func label(_ index: Int) -> String { |
| 40 | let base = "Input \(index + 1)" |
| 41 | guard index < names.count else { return base } |
| 42 | let name = names[index].trimmingCharacters(in: .whitespaces) |
| 43 | if name.isEmpty || name == "\(index + 1)" { return base } |
| 44 | return "\(base) (\(name))" |
| 45 | } |
| 46 | |
| 47 | /// What the ⓘ and the readout call it: "Scarlett 18i8 USB, input 1", |
| 48 | /// or just the name for a one-channel microphone. |
| 49 | public func describe(channel: Int) -> String { |
| 50 | count > 1 ? "\(name), input \(channel + 1)" : name |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// The remembered input for each device, by CoreAudio UID. Input 1 (0) |
| 55 | /// is where every device starts, which is what the module always did. |
| 56 | public struct InputChannelPreference { |
| 57 | static func key(_ uid: String) -> String { "audioInputChannel.\(uid)" } |
| 58 | |
| 59 | /// The remembered channel, 0-based, clamped to the channels the |
| 60 | /// device has now (an interface can be reconfigured with fewer). |
| 61 | public static func load(uid: String, channels: Int, _ defaults: UserDefaults) -> Int { |
| 62 | let saved = defaults.integer(forKey: key(uid)) |
| 63 | return saved >= 0 && saved < max(channels, 1) ? saved : 0 |
| 64 | } |
| 65 | |
| 66 | public static func save(_ channel: Int, uid: String, _ defaults: UserDefaults) { |
| 67 | if channel <= 0 { |
| 68 | defaults.removeObject(forKey: key(uid)) |
| 69 | } else { |
| 70 | defaults.set(channel, forKey: key(uid)) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// The pure parts of the channel map, apart from any device. |
| 76 | public enum InputChannelMap { |
| 77 | /// The input to send from, 0-based, or 0 to leave the unit as the |
| 78 | /// module wired it. Only a microphone the person chose by name (not |
| 79 | /// the system default) with more than one input, and only an input |
| 80 | /// they chose other than the first: 0.3.8 made the map strictly |
| 81 | /// opt-in, so a default or ordinary microphone is wired exactly as it |
| 82 | /// was in 0.3.3, before there was a map at all. |
| 83 | public static func channel(saved: Int, device: String, explicit: String?, inputs: Int) -> Int { |
| 84 | guard let explicit, explicit == device, inputs > 1, saved > 0, saved < inputs else { return 0 } |
| 85 | return saved |
| 86 | } |
| 87 | |
| 88 | /// The map for an input unit whose client side has `clientChannels` |
| 89 | /// channels: every one of them carries device input `channel` |
| 90 | /// (0-based), so a mono mixdown of them — or the first of them — is |
| 91 | /// that input. Nil means leave the unit alone: input 1 on a unit this |
| 92 | /// app has never mapped is exactly what the module does already. |
| 93 | /// |
| 94 | /// `offset` is where the microphone's channels start on the unit's |
| 95 | /// device: 0 for the microphone itself, and past the speaker's own |
| 96 | /// inputs on an aggregate that joins the two (AudioEnginePin), where |
| 97 | /// input 1 of the microphone is not input 1 of the device. |
| 98 | public static func map(channel: Int, clientChannels: Int, mappedBefore: Bool, offset: Int = 0) |
| 99 | -> [Int32]? |
| 100 | { |
| 101 | let width = max(clientChannels, 1) |
| 102 | let source = max(channel, 0) + max(offset, 0) |
| 103 | if source <= 0 { |
| 104 | // Undo an earlier choice on the same unit: straight through. |
| 105 | return mappedBefore ? (0..<width).map { Int32($0) } : nil |
| 106 | } |
| 107 | return Array(repeating: Int32(source), count: width) |
| 108 | } |
| 109 | |
| 110 | /// Input channels in a stream configuration: the sum over its buffers. |
| 111 | public static func count(_ list: UnsafeMutableAudioBufferListPointer) -> Int { |
| 112 | list.reduce(0) { $0 + Int($1.mNumberChannels) } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// CoreAudio, read. Every call here is a property query on the HAL; none |
| 117 | /// opens a device or needs microphone permission. |
| 118 | enum CoreAudioInput { |
| 119 | /// The device for a module device id: its CoreAudio UID, or "default" |
| 120 | /// (the module's own name for the system default). |
| 121 | static func device(forModuleID id: String?) -> AudioDeviceID? { |
| 122 | if let id, id != "default", !id.isEmpty, let device = device(uid: id) { return device } |
| 123 | return defaultInput() |
| 124 | } |
| 125 | |
| 126 | static func device(uid: String) -> AudioDeviceID? { |
| 127 | var address = AudioObjectPropertyAddress( |
| 128 | mSelector: kAudioHardwarePropertyTranslateUIDToDevice, |
| 129 | mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| 130 | var cf = uid as CFString |
| 131 | var device = AudioDeviceID(kAudioObjectUnknown) |
| 132 | var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| 133 | let status = withUnsafeMutablePointer(to: &cf) { pointer in |
| 134 | AudioObjectGetPropertyData( |
| 135 | AudioObjectID(kAudioObjectSystemObject), &address, |
| 136 | UInt32(MemoryLayout<CFString>.size), pointer, &size, &device) |
| 137 | } |
| 138 | return status == noErr && device != kAudioObjectUnknown ? device : nil |
| 139 | } |
| 140 | |
| 141 | static func defaultInput() -> AudioDeviceID? { |
| 142 | var address = AudioObjectPropertyAddress( |
| 143 | mSelector: kAudioHardwarePropertyDefaultInputDevice, |
| 144 | mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) |
| 145 | var device = AudioDeviceID(kAudioObjectUnknown) |
| 146 | var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| 147 | let status = AudioObjectGetPropertyData( |
| 148 | AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device) |
| 149 | return status == noErr && device != kAudioObjectUnknown ? device : nil |
| 150 | } |
| 151 | |
| 152 | static func string(_ device: AudioDeviceID, _ selector: AudioObjectPropertySelector, |
| 153 | scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, |
| 154 | element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain |
| 155 | ) -> String? { |
| 156 | var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element) |
| 157 | guard AudioObjectHasProperty(device, &address) else { return nil } |
| 158 | var value: Unmanaged<CFString>? |
| 159 | var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size) |
| 160 | let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) |
| 161 | guard status == noErr, let value else { return nil } |
| 162 | return value.takeRetainedValue() as String |
| 163 | } |
| 164 | |
| 165 | static func inputChannelCount(_ device: AudioDeviceID) -> Int { |
| 166 | var address = AudioObjectPropertyAddress( |
| 167 | mSelector: kAudioDevicePropertyStreamConfiguration, |
| 168 | mScope: kAudioObjectPropertyScopeInput, mElement: kAudioObjectPropertyElementMain) |
| 169 | var size: UInt32 = 0 |
| 170 | guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr, size > 0 |
| 171 | else { return 0 } |
| 172 | let raw = UnsafeMutableRawPointer.allocate( |
| 173 | byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment) |
| 174 | defer { raw.deallocate() } |
| 175 | let list = raw.bindMemory(to: AudioBufferList.self, capacity: 1) |
| 176 | guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, list) == noErr else { |
| 177 | return 0 |
| 178 | } |
| 179 | return InputChannelMap.count(UnsafeMutableAudioBufferListPointer(list)) |
| 180 | } |
| 181 | |
| 182 | static func describe(_ device: AudioDeviceID) -> InputDeviceChannels? { |
| 183 | guard let uid = string(device, kAudioDevicePropertyDeviceUID) else { return nil } |
| 184 | let name = string(device, kAudioObjectPropertyName) ?? "Microphone" |
| 185 | let count = inputChannelCount(device) |
| 186 | // Names only matter for a picker, which only a multi-channel device |
| 187 | // gets; a USB interface's driver usually names its inputs. |
| 188 | let names = |
| 189 | count > 1 |
| 190 | ? (1...count).map { |
| 191 | string( |
| 192 | device, kAudioObjectPropertyElementName, scope: kAudioObjectPropertyScopeInput, |
| 193 | element: AudioObjectPropertyElement($0)) ?? "" |
| 194 | } : [] |
| 195 | return InputDeviceChannels(uid: uid, name: name, count: count, names: names) |
| 196 | } |
| 197 | |
| 198 | /// The device an input unit is on. |
| 199 | static func device(of unit: AudioUnit) -> AudioDeviceID? { |
| 200 | var device = AudioDeviceID(kAudioObjectUnknown) |
| 201 | var size = UInt32(MemoryLayout<AudioDeviceID>.size) |
| 202 | let status = AudioUnitGetProperty( |
| 203 | unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, &size) |
| 204 | return status == noErr && device != kAudioObjectUnknown ? device : nil |
| 205 | } |
| 206 | |
| 207 | /// Channels on the input unit's client side: what the engine is handed. |
| 208 | static func clientChannels(of unit: AudioUnit) -> Int { |
| 209 | var format = AudioStreamBasicDescription() |
| 210 | var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
| 211 | let status = AudioUnitGetProperty( |
| 212 | unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1, &format, &size) |
| 213 | return status == noErr ? Int(format.mChannelsPerFrame) : 0 |
| 214 | } |
| 215 | |
| 216 | static func setChannelMap(_ map: [Int32], on unit: AudioUnit) -> OSStatus { |
| 217 | var map = map |
| 218 | return AudioUnitSetProperty( |
| 219 | unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Output, 1, &map, |
| 220 | UInt32(MemoryLayout<Int32>.size * map.count)) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Applies the remembered channel to the engine module's input unit. |
| 225 | /// Called from the module's observer on its own thread, when it wires the |
| 226 | /// microphone in and again just before it starts the engine (by which |
| 227 | /// time the unit's client format is the one it will run with). |
| 228 | final class InputChannelMapper: @unchecked Sendable { |
| 229 | private let defaults: UserDefaults |
| 230 | /// The microphone behind an aggregate the engine is on, and where its |
| 231 | /// channels start (AudioEnginePin.inputLayout). Nil for a device that |
| 232 | /// is the microphone itself. |
| 233 | private let layout: (AudioDeviceID) -> (uid: String, offset: Int)? |
| 234 | /// The microphone the person chose, or nil for the system default. |
| 235 | private let explicitInput: () -> String? |
| 236 | private let lock = NSLock() |
| 237 | private var mapped = false |
| 238 | /// What was last applied, for the readout: the device and the channel. |
| 239 | private(set) var applied: (uid: String, channel: Int)? |
| 240 | |
| 241 | init( |
| 242 | defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }, |
| 243 | explicitInput: @escaping () -> String? = { nil } |
| 244 | ) { |
| 245 | self.defaults = defaults |
| 246 | self.layout = layout |
| 247 | self.explicitInput = explicitInput |
| 248 | } |
| 249 | |
| 250 | func apply(to node: AVAudioNode?) { |
| 251 | guard let node = node as? AVAudioInputNode, let unit = node.audioUnit, |
| 252 | let device = CoreAudioInput.device(of: unit), |
| 253 | let unitUID = CoreAudioInput.string(device, kAudioDevicePropertyDeviceUID) |
| 254 | else { return } |
| 255 | let (uid, offset) = layout(device) ?? (unitUID, 0) |
| 256 | let count = CoreAudioInput.device(uid: uid).map(CoreAudioInput.inputChannelCount) ?? 0 |
| 257 | let channel = InputChannelMap.channel( |
| 258 | saved: InputChannelPreference.load(uid: uid, channels: count, defaults), device: uid, |
| 259 | explicit: explicitInput(), inputs: count) |
| 260 | lock.lock() |
| 261 | defer { lock.unlock() } |
| 262 | guard |
| 263 | let map = InputChannelMap.map( |
| 264 | channel: channel, clientChannels: CoreAudioInput.clientChannels(of: unit), |
| 265 | mappedBefore: mapped, offset: offset) |
| 266 | else { |
| 267 | applied = (uid, 0) |
| 268 | return |
| 269 | } |
| 270 | let status = CoreAudioInput.setChannelMap(map, on: unit) |
| 271 | if status == noErr { |
| 272 | mapped = channel + offset > 0 |
| 273 | applied = (uid, channel) |
| 274 | } |
| 275 | AudioLog.info( |
| 276 | "input channel map \(map.map(String.init).joined(separator: ",")) for \(uid) input \(channel + 1): OSStatus \(status)") |
| 277 | } |
| 278 | } |
| 279 | #endif |
| 280 | |