import Foundation import CoreAudio import AVFoundation import os struct PrioritizedDevice: Codable, Identifiable { let id: String let name: String let priority: Int } enum AudioInputMode: String, CaseIterable { case systemDefault = "System Default" case custom = "Custom Device" case prioritized = "Prioritized" } class AudioDeviceManager: ObservableObject { private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioDeviceManager") @Published var availableDevices: [(id: AudioDeviceID, uid: String, name: String)] = [] @Published var selectedDeviceID: AudioDeviceID? @Published var inputMode: AudioInputMode = .systemDefault @Published var prioritizedDevices: [PrioritizedDevice] = [] var fallbackDeviceID: AudioDeviceID? var isRecordingActive: Bool = false static let shared = AudioDeviceManager() init() { setupFallbackDevice() loadPrioritizedDevices() loadAvailableDevices { [weak self] in self?.initializeSelectedDevice() } if let savedMode = UserDefaults.standard.audioInputModeRawValue, let mode = AudioInputMode(rawValue: savedMode) { inputMode = mode } setupDeviceChangeNotifications() } func setupFallbackDevice() { let deviceID: AudioDeviceID? = getDeviceProperty( deviceID: AudioObjectID(kAudioObjectSystemObject), selector: kAudioHardwarePropertyDefaultInputDevice ) if let deviceID = deviceID { fallbackDeviceID = deviceID if let name = getDeviceName(deviceID: deviceID) { logger.info("Fallback device set to: \(name) (ID: \(deviceID))") } } else { logger.error("Failed to get fallback device") } } private func initializeSelectedDevice() { if inputMode == .prioritized { selectHighestPriorityAvailableDevice() return } if let savedUID = UserDefaults.standard.selectedAudioDeviceUID { if let device = availableDevices.first(where: { $0.uid == savedUID }) { selectedDeviceID = device.id logger.info("Loaded saved device UID: \(savedUID), mapped to ID: \(device.id)") if let name = getDeviceName(deviceID: device.id) { logger.info("Using saved device: \(name)") } } else { logger.warning("Saved device UID \(savedUID) is no longer available") UserDefaults.standard.removeObject(forKey: UserDefaults.Keys.selectedAudioDeviceUID) fallbackToDefaultDevice() } } else { fallbackToDefaultDevice() } } private func isDeviceAvailable(_ deviceID: AudioDeviceID) -> Bool { return availableDevices.contains { $0.id == deviceID } } private func fallbackToDefaultDevice() { logger.info("Temporarily falling back to system default input device – user preference remains intact.") if let currentID = selectedDeviceID, !isDeviceAvailable(currentID) { selectedDeviceID = nil } notifyDeviceChange() } func loadAvailableDevices(completion: (() -> Void)? = nil) { logger.info("Loading available audio devices...") var propertySize: UInt32 = 0 var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) var result = AudioObjectGetPropertyDataSize( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &propertySize ) let deviceCount = Int(propertySize) / MemoryLayout.size logger.info("Found \(deviceCount) total audio devices") var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount) result = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &propertySize, &deviceIDs ) if result != noErr { logger.error("Error getting audio devices: \(result)") return } let devices = deviceIDs.compactMap { deviceID -> (id: AudioDeviceID, uid: String, name: String)? in guard let name = getDeviceName(deviceID: deviceID), let uid = getDeviceUID(deviceID: deviceID), isValidInputDevice(deviceID: deviceID) else { return nil } return (id: deviceID, uid: uid, name: name) } logger.info("Found \(devices.count) input devices") devices.forEach { device in logger.info("Available device: \(device.name) (ID: \(device.id))") } DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.availableDevices = devices.map { ($0.id, $0.uid, $0.name) } if let currentID = self.selectedDeviceID, !devices.contains(where: { $0.id == currentID }) { self.logger.warning("Currently selected device is no longer available") self.fallbackToDefaultDevice() } completion?() } } func getDeviceName(deviceID: AudioDeviceID) -> String? { let name: CFString? = getDeviceProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceNameCFString) return name as String? } private func isValidInputDevice(deviceID: AudioDeviceID) -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, mScope: kAudioDevicePropertyScopeInput, mElement: kAudioObjectPropertyElementMain ) var propertySize: UInt32 = 0 var result = AudioObjectGetPropertyDataSize( deviceID, &address, 0, nil, &propertySize ) if result != noErr { logger.error("Error checking input capability for device \(deviceID): \(result)") return false } let bufferList = UnsafeMutablePointer.allocate(capacity: Int(propertySize)) defer { bufferList.deallocate() } result = AudioObjectGetPropertyData( deviceID, &address, 0, nil, &propertySize, bufferList ) if result != noErr { logger.error("Error getting stream configuration for device \(deviceID): \(result)") return false } let bufferCount = Int(bufferList.pointee.mNumberBuffers) guard bufferCount > 0 else { return false } address.mSelector = kAudioDevicePropertyTransportType address.mScope = kAudioObjectPropertyScopeGlobal var transportType: UInt32 = 0 propertySize = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData( deviceID, &address, 0, nil, &propertySize, &transportType ) if status != noErr { logger.warning("Could not get transport type for device \(deviceID), including it anyway") return true } let isVirtual = transportType == kAudioDeviceTransportTypeVirtual let isAggregate = transportType == kAudioDeviceTransportTypeAggregate return !isVirtual && !isAggregate } func selectDevice(id: AudioDeviceID) { logger.info("Selecting device with ID: \(id)") if let name = getDeviceName(deviceID: id) { logger.info("Selected device name: \(name)") } if let deviceToSelect = availableDevices.first(where: { $0.id == id }) { let uid = deviceToSelect.uid DispatchQueue.main.async { self.selectedDeviceID = id UserDefaults.standard.selectedAudioDeviceUID = uid self.logger.info("Device selection saved with UID: \(uid)") self.notifyDeviceChange() } } else { logger.error("Attempted to select unavailable device: \(id)") fallbackToDefaultDevice() } } func selectDeviceAndSwitchToCustomMode(id: AudioDeviceID) { if let deviceToSelect = availableDevices.first(where: { $0.id == id }) { let uid = deviceToSelect.uid DispatchQueue.main.async { self.inputMode = .custom self.selectedDeviceID = id UserDefaults.standard.audioInputModeRawValue = AudioInputMode.custom.rawValue UserDefaults.standard.selectedAudioDeviceUID = uid self.notifyDeviceChange() } } else { logger.error("Attempted to select unavailable device: \(id)") fallbackToDefaultDevice() } } func selectInputMode(_ mode: AudioInputMode) { inputMode = mode UserDefaults.standard.audioInputModeRawValue = mode.rawValue if mode == .systemDefault { selectedDeviceID = nil UserDefaults.standard.removeObject(forKey: UserDefaults.Keys.selectedAudioDeviceUID) } else if selectedDeviceID == nil { if inputMode == .custom { if let firstDevice = availableDevices.first { selectDevice(id: firstDevice.id) } } else if inputMode == .prioritized { selectHighestPriorityAvailableDevice() } } notifyDeviceChange() } func getCurrentDevice() -> AudioDeviceID { switch inputMode { case .systemDefault: return fallbackDeviceID ?? 0 case .custom: if let id = selectedDeviceID, isDeviceAvailable(id) { return id } else { return fallbackDeviceID ?? 0 } case .prioritized: let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority } for device in sortedDevices { if let available = availableDevices.first(where: { $0.uid == device.id }) { return available.id } } return fallbackDeviceID ?? 0 } } private func loadPrioritizedDevices() { if let data = UserDefaults.standard.prioritizedDevicesData, let devices = try? JSONDecoder().decode([PrioritizedDevice].self, from: data) { prioritizedDevices = devices logger.info("Loaded \(devices.count) prioritized devices") } } func savePrioritizedDevices() { if let data = try? JSONEncoder().encode(prioritizedDevices) { UserDefaults.standard.prioritizedDevicesData = data logger.info("Saved \(self.prioritizedDevices.count) prioritized devices") } } func addPrioritizedDevice(uid: String, name: String) { guard !prioritizedDevices.contains(where: { $0.id == uid }) else { return } let nextPriority = (prioritizedDevices.map { $0.priority }.max() ?? -1) + 1 let device = PrioritizedDevice(id: uid, name: name, priority: nextPriority) prioritizedDevices.append(device) savePrioritizedDevices() } func removePrioritizedDevice(id: String) { let wasSelected = selectedDeviceID == availableDevices.first(where: { $0.uid == id })?.id prioritizedDevices.removeAll { $0.id == id } let updatedDevices = prioritizedDevices.enumerated().map { index, device in PrioritizedDevice(id: device.id, name: device.name, priority: index) } prioritizedDevices = updatedDevices savePrioritizedDevices() if wasSelected && inputMode == .prioritized { selectHighestPriorityAvailableDevice() } } func updatePriorities(devices: [PrioritizedDevice]) { prioritizedDevices = devices savePrioritizedDevices() if inputMode == .prioritized { selectHighestPriorityAvailableDevice() } notifyDeviceChange() } private func selectHighestPriorityAvailableDevice() { let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority } for device in sortedDevices { if let availableDevice = availableDevices.first(where: { $0.uid == device.id }) { selectedDeviceID = availableDevice.id logger.info("Selected prioritized device: \(device.name) (Priority: \(device.priority))") do { try AudioDeviceConfiguration.setDefaultInputDevice(availableDevice.id) } catch { logger.error("Failed to set prioritized device: \(error.localizedDescription)") continue } notifyDeviceChange() return } } fallbackToDefaultDevice() } private func setupDeviceChangeNotifications() { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let systemObjectID = AudioObjectID(kAudioObjectSystemObject) let status = AudioObjectAddPropertyListener( systemObjectID, &address, { (_, _, _, userData) -> OSStatus in let manager = Unmanaged.fromOpaque(userData!).takeUnretainedValue() DispatchQueue.main.async { manager.handleDeviceListChange() } return noErr }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) if status != noErr { logger.error("Failed to add device change listener: \(status)") } else { logger.info("Successfully added device change listener") } } private func handleDeviceListChange() { logger.info("Device list change detected") loadAvailableDevices { [weak self] in guard let self = self else { return } if self.inputMode == .prioritized { self.selectHighestPriorityAvailableDevice() } else if self.inputMode == .custom, let currentID = self.selectedDeviceID, !self.isDeviceAvailable(currentID) { self.fallbackToDefaultDevice() } } } private func getDeviceUID(deviceID: AudioDeviceID) -> String? { let uid: CFString? = getDeviceProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceUID) return uid as String? } deinit { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectRemovePropertyListener( AudioObjectID(kAudioObjectSystemObject), &address, { (_, _, _, userData) -> OSStatus in return noErr }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) } private func createPropertyAddress(selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) -> AudioObjectPropertyAddress { return AudioObjectPropertyAddress( mSelector: selector, mScope: scope, mElement: element ) } private func getDeviceProperty(deviceID: AudioDeviceID, selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> T? { guard deviceID != 0 else { return nil } var address = createPropertyAddress(selector: selector, scope: scope) var propertySize = UInt32(MemoryLayout.size) var property: T? = nil let status = AudioObjectGetPropertyData( deviceID, &address, 0, nil, &propertySize, &property ) if status != noErr { logger.error("Failed to get device property \(selector) for device \(deviceID): \(status)") return nil } return property } private func notifyDeviceChange() { if !isRecordingActive { NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) } } }