Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
} 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,
Expand Down
22 changes: 22 additions & 0 deletions Sources/Fluid/Services/MacLidStateProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 49 additions & 0 deletions Sources/Fluid/Services/MicrophonePreferenceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
144 changes: 144 additions & 0 deletions Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading