import Foundation import CoreAudio import AVFoundation import os struct PrioritizedDevice: Codable, Identifiable { let id: String // Device UID 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] = [] private var fallbackDeviceID: AudioDeviceID? var isRecordingActive: Bool = false static let shared = AudioDeviceManager() init() { setupFallbackDevice() loadPrioritizedDevices() loadAvailableDevices { [weak self] in self?.initializeSelectedDevice() } // Load saved input mode if let savedMode = UserDefaults.standard.string(forKey: "audioInputMode"), let mode = AudioInputMode(rawValue: savedMode) { inputMode = mode } // Setup device change notifications setupDeviceChangeNotifications() } private 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 } // Try to load saved device if let savedID = UserDefaults.standard.object(forKey: "selectedAudioDeviceID") as? AudioDeviceID { // Verify the saved device still exists and is valid if isDeviceAvailable(savedID) { selectedDeviceID = savedID logger.info("Loaded saved device ID: \(savedID)") if let name = getDeviceName(deviceID: savedID) { logger.info("Using saved device: \(name)") } } else { logger.warning("Saved device ID \(savedID) is no longer available") fallbackToDefaultDevice() } } else { fallbackToDefaultDevice() } } private func isDeviceAvailable(_ deviceID: AudioDeviceID) -> Bool { return availableDevices.contains { $0.id == deviceID } } private func fallbackToDefaultDevice() { if let fallbackID = fallbackDeviceID { selectedDeviceID = fallbackID logger.info("Using fallback device ID: \(fallbackID)") if let name = getDeviceName(deviceID: fallbackID) { logger.info("Fallback to built-in microphone: \(name)") } } else { logger.error("No fallback device available") } } 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), isInputDevice(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) } // Verify current selection is still valid 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 isInputDevice(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) return bufferCount > 0 } func selectDevice(id: AudioDeviceID) { logger.info("Selecting device with ID: \(id)") if let name = getDeviceName(deviceID: id) { logger.info("Selected device name: \(name)") } if isDeviceAvailable(id) { DispatchQueue.main.async { self.selectedDeviceID = id UserDefaults.standard.set(id, forKey: "selectedAudioDeviceID") self.logger.info("Device selection saved") self.notifyDeviceChange() } } else { logger.error("Attempted to select unavailable device: \(id)") fallbackToDefaultDevice() } } func selectInputMode(_ mode: AudioInputMode) { inputMode = mode UserDefaults.standard.set(mode.rawValue, forKey: "audioInputMode") if mode == .systemDefault { selectedDeviceID = nil UserDefaults.standard.removeObject(forKey: "selectedAudioDeviceID") } else if selectedDeviceID == nil { if let firstDevice = availableDevices.first { selectDevice(id: firstDevice.id) } } notifyDeviceChange() } func getCurrentDevice() -> AudioDeviceID { switch inputMode { case .systemDefault: return fallbackDeviceID ?? 0 case .custom: return selectedDeviceID ?? 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.data(forKey: "prioritizedDevices"), 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.set(data, forKey: "prioritizedDevices") 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 } // Reindex remaining devices to ensure continuous priority numbers let updatedDevices = prioritizedDevices.enumerated().map { index, device in PrioritizedDevice(id: device.id, name: device.name, priority: index) } prioritizedDevices = updatedDevices savePrioritizedDevices() // If we removed the currently selected device, select the next best option if wasSelected && inputMode == .prioritized { selectHighestPriorityAvailableDevice() } } func updatePriorities(devices: [PrioritizedDevice]) { prioritizedDevices = devices savePrioritizedDevices() if inputMode == .prioritized { selectHighestPriorityAvailableDevice() } notifyDeviceChange() } private func selectHighestPriorityAvailableDevice() { // Sort by priority (lowest number = highest priority) let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority } // Try each device in priority order 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))") // Actually set the device as the current input device do { try AudioDeviceConfiguration.setDefaultInputDevice(availableDevice.id) UserDefaults.standard.set(availableDevice.id, forKey: "selectedAudioDeviceID") } catch { logger.error("Failed to set prioritized device: \(error.localizedDescription)") continue // Try next device if this one fails } return } } // If no prioritized device is available, fall back to default fallbackToDefaultDevice() } private func setupDeviceChangeNotifications() { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let systemObjectID = AudioObjectID(kAudioObjectSystemObject) // Add listener for device changes 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 in prioritized mode, recheck the device selection if self.inputMode == .prioritized { self.selectHighestPriorityAvailableDevice() } // If in custom mode and selected device is no longer available, fallback else if self.inputMode == .custom, let currentID = self.selectedDeviceID, !self.isDeviceAvailable(currentID) { self.fallbackToDefaultDevice() } // Notify UI of changes if !self.isRecordingActive { NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) } } } private func getDeviceUID(deviceID: AudioDeviceID) -> String? { let uid: CFString? = getDeviceProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceUID) return uid as String? } deinit { // Remove the listener when the manager is deallocated var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) AudioObjectRemovePropertyListener( AudioObjectID(kAudioObjectSystemObject), &address, { (_, _, _, userData) -> OSStatus in return noErr }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) ) } // MARK: - Helper Methods 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? { // Skip invalid device IDs 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() { // Only notify if recording is not active if !isRecordingActive { NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) } } }