oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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