diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 81f6223c..111b36fa 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -212,6 +212,12 @@ final class ASRService: ObservableObject { @Published var errorMessage: String = "" @Published var showError: Bool = false + func presentAudioCaptureStartFailure(_ message: String) { + self.errorTitle = "Recording Failed" + self.errorMessage = message + self.showError = true + } + /// Returns a user-friendly status message for model loading state var modelStatusMessage: String { if self.isAsrReady { return "Model ready" } @@ -908,13 +914,41 @@ final class ASRService: ObservableObject { AppServices.shared.microphonePreferenceCoordinator.inputDeviceForCapture() } - private func directCoreAudioDeviceSelection() -> DirectCoreAudioDeviceSelection { - if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, - preferredUID.isEmpty == false - { - return .preferredUID(preferredUID) + private func directCoreAudioDeviceSelection() throws -> DirectCoreAudioDeviceSelection { + switch AppServices.shared.microphonePreferenceCoordinator.captureResolution() { + case .standard: + if let preferredUID = SettingsStore.shared.preferredInputDeviceUID, + preferredUID.isEmpty == false + { + return .preferredUID(preferredUID) + } + return .systemDefault + case let .clamshellSelectedExternal(device): + return .preferredUID(device.uid) + case let .clamshellFallback(device): + DebugLogger.shared.warning( + "Built-in microphone is unavailable with the lid closed; " + + "using external input '\(device.name)' for this capture.", + source: "ASRService" + ) + return .preferredUID(device.uid) + case .clamshellRequiresExternalMicrophone: + throw NSError( + domain: "ASRService", + code: -3, + userInfo: [ + NSLocalizedDescriptionKey: + "The built-in microphone is disconnected while your MacBook lid is closed. " + + "Connect and select an external microphone, or open the lid.", + ] + ) + case .unavailable: + throw NSError( + domain: "ASRService", + code: -4, + userInfo: [NSLocalizedDescriptionKey: "No audio input device is available."] + ) } - return .systemDefault } /// Prepares the direct device callback without starting hardware IO. This @@ -1496,7 +1530,11 @@ final class ASRService: ObservableObject { do { try await self.startConfiguredAudioCapture() } catch { - guard startAttempt < maximumStartAttempts, + let nsError = error as NSError + let isNonRetryableInputResolutionError = + nsError.domain == "ASRService" && [-3, -4].contains(nsError.code) + guard isNonRetryableInputResolutionError == false, + startAttempt < maximumStartAttempts, startGeneration == self.audioCaptureStartGeneration, self.isTerminating == false else { @@ -1673,13 +1711,15 @@ final class ASRService: ObservableObject { errorMessage = "Failed to start audio recording: \(underlyingError.localizedDescription)" } } else { - errorMessage = "Failed to start audio recording after multiple attempts. Please check your audio device and try again." + errorMessage = nsError.localizedDescription } } else { errorMessage = "Failed to start audio recording: \(error.localizedDescription)" } - // Post notification for UI to display + self.presentAudioCaptureStartFailure(errorMessage) + + // Preserve the existing notification for external observers. NotificationCenter.default.post( name: NSNotification.Name("ASRServiceStartFailed"), object: nil, diff --git a/Sources/Fluid/Services/MacLidStateProvider.swift b/Sources/Fluid/Services/MacLidStateProvider.swift new file mode 100644 index 00000000..bf786f38 --- /dev/null +++ b/Sources/Fluid/Services/MacLidStateProvider.swift @@ -0,0 +1,22 @@ +import Foundation +import IOKit + +enum MacLidState { + static func isClosed() -> Bool? { + let rootDomain = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("IOPMrootDomain") + ) + guard rootDomain != MACH_PORT_NULL else { return nil } + defer { IOObjectRelease(rootDomain) } + + guard let value = IORegistryEntryCreateCFProperty( + rootDomain, + "AppleClamshellState" as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() else { return nil } + + return (value as? NSNumber)?.boolValue + } +} diff --git a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift index 998beb6a..135f93c6 100644 --- a/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift +++ b/Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift @@ -17,6 +17,14 @@ struct CoreAudioDeviceManager: AudioDeviceManaging { } } +enum MicrophoneCaptureResolution: Equatable { + case standard + case clamshellSelectedExternal(AudioDevice.Device) + case clamshellFallback(AudioDevice.Device) + case clamshellRequiresExternalMicrophone + case unavailable +} + @MainActor final class MicrophonePreferenceCoordinator: ObservableObject { private static let appOnlyMigrationVersion = 1 @@ -123,6 +131,47 @@ final class MicrophonePreferenceCoordinator: ObservableObject { return fallback } + func captureResolution() -> MicrophoneCaptureResolution { + let isLidClosed = MacLidState.isClosed() + guard isLidClosed == true else { return .standard } + + let inputs = self.devices.listInputDevices() + return self.captureResolution( + availableInputs: inputs, + defaultInputUID: self.devices.defaultInputDevice()?.uid, + isLidClosed: isLidClosed + ) + } + + func captureResolution( + availableInputs: [AudioDevice.Device], + defaultInputUID: String? = nil, + isLidClosed: Bool? + ) -> MicrophoneCaptureResolution { + guard isLidClosed == true else { return .standard } + + guard let selectedInput = self.inputDeviceForCapture( + availableInputs: availableInputs, + defaultInputUID: defaultInputUID + ) else { + return .unavailable + } + guard selectedInput.isBuiltIn else { + return .clamshellSelectedExternal(selectedInput) + } + + let externalInputs = availableInputs.filter { $0.isBuiltIn == false } + if let defaultInputUID, + let defaultExternalInput = externalInputs.first(where: { $0.uid == defaultInputUID }) + { + return .clamshellFallback(defaultExternalInput) + } + if let externalInput = externalInputs.first { + return .clamshellFallback(externalInput) + } + return .clamshellRequiresExternalMicrophone + } + private func fallbackInput( from inputs: [AudioDevice.Device], defaultInputUID: String? diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 8292a846..8decb0e8 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -456,6 +456,150 @@ final class HotkeyShortcutTests: XCTestCase { XCTAssertFalse(builtInDevice.isBluetooth) } + @MainActor + func testClamshellCaptureKeepsSelectedExternalMicrophone() { + self.withRestoredDefaults(keys: [self.preferredInputDeviceUIDKey]) { + SettingsStore.shared.preferredInputDeviceUID = "usb" + let builtIn = Self.device( + uid: "internal", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let usb = Self.device(uid: "usb", name: "USB Microphone") + let devices = FakeAudioDeviceManager( + inputs: [builtIn, usb], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolution = coordinator.captureResolution( + availableInputs: devices.inputs, + defaultInputUID: devices.defaultInputUID, + isLidClosed: true + ) + + XCTAssertEqual(resolution, .clamshellSelectedExternal(usb)) + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "usb") + } + } + + @MainActor + func testClamshellCaptureTemporarilyUsesDefaultExternalMicrophone() { + self.withRestoredDefaults(keys: [self.preferredInputDeviceUIDKey]) { + SettingsStore.shared.preferredInputDeviceUID = "internal" + let builtIn = Self.device( + uid: "internal", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let display = Self.device(uid: "display", name: "Display Microphone") + let usb = Self.device(uid: "usb", name: "USB Microphone") + let devices = FakeAudioDeviceManager( + inputs: [builtIn, display, usb], + defaultInputUID: "usb" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolution = coordinator.captureResolution( + availableInputs: devices.inputs, + defaultInputUID: devices.defaultInputUID, + isLidClosed: true + ) + + XCTAssertEqual(resolution, .clamshellFallback(usb)) + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "internal") + } + } + + @MainActor + func testClamshellCaptureUsesAvailableExternalWhenDefaultIsBuiltIn() { + self.withRestoredDefaults(keys: [self.preferredInputDeviceUIDKey]) { + SettingsStore.shared.preferredInputDeviceUID = "internal" + let builtIn = Self.device( + uid: "internal", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let usb = Self.device(uid: "usb", name: "USB Microphone") + let devices = FakeAudioDeviceManager( + inputs: [builtIn, usb], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolution = coordinator.captureResolution( + availableInputs: devices.inputs, + defaultInputUID: devices.defaultInputUID, + isLidClosed: true + ) + + XCTAssertEqual(resolution, .clamshellFallback(usb)) + XCTAssertEqual(SettingsStore.shared.preferredInputDeviceUID, "internal") + } + } + + @MainActor + func testClamshellCaptureRejectsBuiltInMicrophoneWithoutExternalInput() { + self.withRestoredDefaults(keys: [self.preferredInputDeviceUIDKey]) { + SettingsStore.shared.preferredInputDeviceUID = "internal" + let builtIn = Self.device( + uid: "internal", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let devices = FakeAudioDeviceManager( + inputs: [builtIn], + defaultInputUID: "internal" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolution = coordinator.captureResolution( + availableInputs: devices.inputs, + defaultInputUID: devices.defaultInputUID, + isLidClosed: true + ) + + XCTAssertEqual(resolution, .clamshellRequiresExternalMicrophone) + } + } + + @MainActor + func testOpenLidCaptureKeepsStandardRouting() { + self.withRestoredDefaults(keys: [self.preferredInputDeviceUIDKey]) { + SettingsStore.shared.preferredInputDeviceUID = "internal" + let builtIn = Self.device( + uid: "internal", + name: "MacBook Pro Microphone", + transportType: kAudioDeviceTransportTypeBuiltIn + ) + let usb = Self.device(uid: "usb", name: "USB Microphone") + let devices = FakeAudioDeviceManager( + inputs: [builtIn, usb], + defaultInputUID: "usb" + ) + let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices) + + let resolution = coordinator.captureResolution( + availableInputs: devices.inputs, + defaultInputUID: devices.defaultInputUID, + isLidClosed: false + ) + + XCTAssertEqual(resolution, .standard) + } + } + + @MainActor + func testAudioCaptureStartFailureUsesExistingAlertState() { + let service = ASRService() + + service.presentAudioCaptureStartFailure("Connect an external microphone.") + + XCTAssertEqual(service.errorTitle, "Recording Failed") + XCTAssertEqual(service.errorMessage, "Connect an external microphone.") + XCTAssertTrue(service.showError) + } + @MainActor func testMicrophoneMigrationChoosesBuiltInOnce() throws { try self.withRestoredDefaults(keys: [