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
179 changes: 17 additions & 162 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1372,9 +1372,15 @@ final class ASRService: ObservableObject {
self.isDictionaryTrainingCaptureActive = false

do {
if SettingsStore.shared.microphoneSelectionMode == .manual {
AppServices.shared.microphonePreferenceCoordinator.enforcePreferredInput(reason: "recording start")
}
let microphoneCoordinator = AppServices.shared.microphonePreferenceCoordinator
let enforcementResult = microphoneCoordinator.enforcePreferredInput(reason: "recording start")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve manual capture when default routing fails

When manual mode's setDefaultInputDevice call fails, enforcePreferredInput returns .failed while leaving the prior default unchanged (as the added coordinator test verifies), but this result is only logged. With direct capture disabled (the default), the compatibility AVAudioEngine path now follows that stale default because the per-engine binding was removed, so recording silently uses the wrong microphone instead of the user's selected manual device.

Useful? React with πŸ‘Β / πŸ‘Ž.

let effectiveInput = AudioDevice.getDefaultInputDevice()
DebugLogger.shared.info(
"Microphone routing prepared: mode=\(SettingsStore.shared.microphoneSelectionMode.rawValue), " +
"enforcement=\(String(describing: enforcementResult)), " +
"effective=\(effectiveInput.map { "'\($0.name)' (uid: \($0.uid))" } ?? "unavailable")",
source: "ASRService"
)

try await self.startPreferredAudioCapture()
self.isDictionaryTrainingCaptureActive = forDictionaryTraining
Expand Down Expand Up @@ -1943,51 +1949,12 @@ final class ASRService: ObservableObject {
_ = engine.outputNode
DebugLogger.shared.debug("βœ… Output node instantiated", source: "ASRService")

// NOTE: Device binding occurs in startEngine() BEFORE engine.prepare()
// Per CoreAudio docs, device must be set before AudioUnit initialization (prepare)
// Since sync mode is always ON, binding actually no-ops and uses system defaults
// AVAudioEngine follows the current macOS default input. Manual microphone mode
// reasserts the preferred device as that default before this engine is configured.

DebugLogger.shared.debug("βœ… configureSession() - COMPLETED", source: "ASRService")
}

/// In independent mode, attempt to bind AVAudioEngine's input to the user's preferred input device.
/// In sync-with-system mode, we intentionally do nothing so the engine follows macOS defaults.
/// Returns true if binding succeeded or if no binding was needed, false if binding failed completely.
@discardableResult
private func bindPreferredInputDeviceIfNeeded() -> Bool {
DebugLogger.shared.debug("bindPreferredInputDeviceIfNeeded() - Starting input device binding", source: "ASRService")

guard SettingsStore.shared.microphoneSelectionMode == .manual else {
DebugLogger.shared.info("Using current macOS default input device", source: "ASRService")
return true
}

guard let device = self.resolvedInputDeviceForCapture() else {
DebugLogger.shared.error(
"No input device available for manual microphone capture.",
source: "ASRService"
)
return false
}

DebugLogger.shared.debug(
"Attempting to bind AVAudioEngine input to capture device '\(device.name)' (uid: \(device.uid))",
source: "ASRService"
)

let ok = self.setEngineInputDevice(deviceID: device.id, deviceUID: device.uid, deviceName: device.name)
if ok == false {
DebugLogger.shared.warning(
"Failed to bind engine input to '\(device.name)' (uid: \(device.uid)). Trying system default input.",
source: "ASRService"
)
return self.tryBindToSystemDefaultInput()
}

DebugLogger.shared.info("βœ… Bound AVAudioEngine input to '\(device.name)'", source: "ASRService")
return true
}

/// In independent mode, attempt to bind AVAudioEngine's output to the user's preferred output device.
/// In sync-with-system mode, we intentionally do nothing so the engine follows macOS defaults.
/// Returns true if binding succeeded or if no binding was needed, false if binding failed completely.
Expand All @@ -1999,38 +1966,6 @@ final class ASRService: ObservableObject {
return true
}

/// Attempts to bind to the system default input device as a fallback.
/// Returns true if binding succeeded, false otherwise.
private func tryBindToSystemDefaultInput() -> Bool {
guard let defaultDevice = AudioDevice.getDefaultInputDevice() else {
DebugLogger.shared.error(
"No system default input device available. Cannot start audio capture.",
source: "ASRService"
)
return false
}

DebugLogger.shared.info(
"Attempting to bind to system default input: '\(defaultDevice.name)' (uid: \(defaultDevice.uid))",
source: "ASRService"
)

let ok = self.setEngineInputDevice(
deviceID: defaultDevice.id,
deviceUID: defaultDevice.uid,
deviceName: defaultDevice.name
)

if !ok {
DebugLogger.shared.error(
"Failed to bind to system default input device '\(defaultDevice.name)'. Audio capture cannot proceed.",
source: "ASRService"
)
}

return ok
}

/// Attempts to bind to the system default output device as a fallback.
/// Returns true if binding succeeded, false otherwise.
private func tryBindToSystemDefaultOutput() -> Bool {
Expand Down Expand Up @@ -2065,55 +2000,6 @@ final class ASRService: ObservableObject {
return ok
}

/// Selects a specific CoreAudio device for AVAudioEngine's input node without changing system defaults.
/// This uses the AUHAL AudioUnit backing `engine.inputNode` on macOS.
@discardableResult
private func setEngineInputDevice(deviceID: AudioObjectID, deviceUID: String, deviceName: String) -> Bool {
DebugLogger.shared.debug("setEngineInputDevice() - Binding input to device ID: \(deviceID)", source: "ASRService")

let inputNode = self.engine.inputNode

// `AVAudioInputNode` is backed by an AudioUnit on macOS. Setting this property selects
// which physical device the node captures from.
guard let audioUnit = inputNode.audioUnit else {
DebugLogger.shared.error(
"Unable to access AudioUnit for AVAudioEngine.inputNode; cannot bind to '\(deviceName)' (uid: \(deviceUID))",
source: "ASRService"
)
return false
}

var mutableDeviceID = deviceID
let status = AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global,
0,
&mutableDeviceID,
UInt32(MemoryLayout<AudioObjectID>.size)
)

if status != noErr {
// OSStatus -10851 (kAudioUnitErr_InvalidPropertyValue) occurs for aggregate devices (Bluetooth, etc.)
// This is expected for certain device types - not a fatal error
if status == -10_851 {
DebugLogger.shared.warning(
"Cannot bind INPUT to '\(deviceName)' - likely an aggregate device (OSStatus: \(status)). Will use system default.",
source: "ASRService"
)
} else {
DebugLogger.shared.error(
"AudioUnitSetProperty(CurrentDevice) failed for INPUT '\(deviceName)' (uid: \(deviceUID), id: \(deviceID)) with OSStatus: \(status)",
source: "ASRService"
)
}
return false
}

DebugLogger.shared.info("βœ… Bound ASR input to '\(deviceName)' (uid: \(deviceUID), id: \(deviceID))", source: "ASRService")
return true
}

/// Selects a specific CoreAudio device for AVAudioEngine's output node without changing system defaults.
/// This uses the AUHAL AudioUnit backing `engine.outputNode` on macOS.
@discardableResult
Expand Down Expand Up @@ -2163,34 +2049,6 @@ final class ASRService: ObservableObject {
return true
}

/// Explicitly unbinds the input device from AVAudioEngine's AudioUnit
/// This is CRITICAL for releasing Bluetooth devices so macOS can switch back to high-quality A2DP mode
private func unbindInputDevice() {
DebugLogger.shared.debug("unbindInputDevice() - Releasing input device binding to restore Bluetooth quality", source: "ASRService")

guard let audioUnit = self.engine.inputNode.audioUnit else {
DebugLogger.shared.warning("No AudioUnit for input node - cannot unbind device", source: "ASRService")
return
}

// Set device to kAudioObjectUnknown (0) to explicitly release the device binding
var unknownDevice = AudioObjectID(kAudioObjectUnknown)
let status = AudioUnitSetProperty(
audioUnit,
kAudioOutputUnitProperty_CurrentDevice,
kAudioUnitScope_Global,
0,
&unknownDevice,
UInt32(MemoryLayout<AudioObjectID>.size)
)

if status == noErr {
DebugLogger.shared.info("βœ… Input device unbound - Bluetooth can now return to high-quality mode", source: "ASRService")
} else {
DebugLogger.shared.error("❌ Failed to unbind input device: OSStatus \(status)", source: "ASRService")
}
}

/// Explicitly unbinds the output device from AVAudioEngine's AudioUnit
/// This ensures complete release of audio device resources
private func unbindOutputDevice() {
Expand Down Expand Up @@ -2226,21 +2084,18 @@ final class ASRService: ObservableObject {

while attempts < 3 {
do {
// CRITICAL: Bind devices BEFORE prepare() - must be set before AudioUnit initialization
// Note: This may fail for aggregate devices (Bluetooth, etc.) with OSStatus -10851
// In that case, we fall back to system defaults (same as sync mode)
DebugLogger.shared.debug("🎚️ Binding input device (before prepare)...", source: "ASRService")
let inputBindOk = self.bindPreferredInputDeviceIfNeeded()
DebugLogger.shared.debug("βœ… Input device binding result: \(inputBindOk)", source: "ASRService")
DebugLogger.shared.debug(
"🎚️ AVAudioEngine following current macOS default input device",
source: "ASRService"
)

DebugLogger.shared.debug("πŸ”Š Binding output device (before prepare)...", source: "ASRService")
let outputBindOk = self.bindPreferredOutputDeviceIfNeeded()
DebugLogger.shared.debug("βœ… Output device binding result: \(outputBindOk)", source: "ASRService")

// If binding failed (e.g., aggregate device), engine will use system defaults
if !inputBindOk || !outputBindOk {
if !outputBindOk {
DebugLogger.shared.info(
"⚠️ Device binding failed (likely aggregate device). Engine will use system default devices.",
"⚠️ Output binding failed. Engine will use the system default output device.",
source: "ASRService"
)
}
Expand Down
36 changes: 33 additions & 3 deletions Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,32 @@ final class HotkeyShortcutTests: XCTestCase {
}
}

@MainActor
func testMicrophoneCoordinatorPreservesDefaultWhenManualPreferenceCannotBeApplied() throws {
try self.withRestoredDefaults(keys: [
self.microphoneSelectionModeKey,
self.preferredInputDeviceUIDKey,
]) {
SettingsStore.shared.microphoneSelectionMode = .manual
SettingsStore.shared.preferredInputDeviceUID = "studio-mic"
let devices = FakeAudioDeviceManager(
inputs: [
Self.device(uid: "internal", name: "MacBook Pro Microphone"),
Self.device(uid: "studio-mic", name: "Studio Mic"),
],
defaultInputUID: "internal",
setInputSucceeds: false
)
let coordinator = MicrophonePreferenceCoordinator(settings: .shared, devices: devices)

let result = coordinator.enforcePreferredInput(reason: "unit test")

XCTAssertEqual(result, .failed("studio-mic"))
XCTAssertEqual(devices.setInputCalls, ["studio-mic"])
XCTAssertEqual(devices.defaultInputUID, "internal")
}
}

@MainActor
func testMicrophoneCoordinatorKeepsUnavailableManualPreference() throws {
try self.withRestoredDefaults(keys: [
Expand Down Expand Up @@ -480,11 +506,13 @@ final class HotkeyShortcutTests: XCTestCase {
private final class FakeAudioDeviceManager: AudioDeviceManaging {
let inputs: [AudioDevice.Device]
var defaultInputUID: String?
private let setInputSucceeds: Bool
private(set) var setInputCalls: [String] = []

init(inputs: [AudioDevice.Device], defaultInputUID: String?) {
init(inputs: [AudioDevice.Device], defaultInputUID: String?, setInputSucceeds: Bool = true) {
self.inputs = inputs
self.defaultInputUID = defaultInputUID
self.setInputSucceeds = setInputSucceeds
}

func listInputDevices() -> [AudioDevice.Device] {
Expand All @@ -498,7 +526,9 @@ private final class FakeAudioDeviceManager: AudioDeviceManaging {

func setDefaultInputDevice(uid: String) -> Bool {
self.setInputCalls.append(uid)
self.defaultInputUID = uid
return true
if self.setInputSucceeds {
self.defaultInputUID = uid
}
return self.setInputSucceeds
}
}
Loading