-
-
Notifications
You must be signed in to change notification settings - Fork 572
feat(audio): per-app input device selection via the direct Core Audio path #642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ba52b9d
dc53e92
c55f060
78691e5
e50f29c
e6251e1
18c10d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -621,6 +621,10 @@ final class ASRService: ObservableObject { | |
| private var directAudioInput: DirectCoreAudioInput? | ||
| private var activeAudioCaptureBackend: AudioCaptureBackend = .none | ||
| private var isFallingBackFromDirectCapture = false | ||
| /// UID of the device the FluidVoice-only "preferred mic unavailable" notification last named, so | ||
| /// a single fallback announces once rather than on every recording. Reset (to nil) when the | ||
| /// preferred device is used again. See `announcePreferredMicrophoneFallbackIfNeeded(recording:)`. | ||
| private var lastAnnouncedFallbackDeviceUID: String? | ||
|
|
||
| private var hasPreparedAudioCapture: Bool { | ||
| self.directAudioInput != nil || self.hasWarmAudioEngine | ||
|
|
@@ -809,6 +813,12 @@ final class ASRService: ObservableObject { | |
| } | ||
|
|
||
| private func startPreferredAudioCapture() async throws { | ||
| // FluidVoice-only mode prefers one microphone but does not require it. If the preferred | ||
| // device is unavailable — unplugged, or unbindable on this hardware — capture records the | ||
| // macOS default instead, through this same private path and without ever moving the system | ||
| // input. That substitution is announced (never silent) at whichever backend actually starts, | ||
| // so this mode records rather than refuses. | ||
|
|
||
| // A non-route path may have scheduled a fire-and-forget retirement. Do | ||
| // not let capture startup overlap any final AVAudioEngine release that is | ||
| // already queued. | ||
|
|
@@ -830,6 +840,9 @@ final class ASRService: ObservableObject { | |
| "audio_backend kind=direct_core_audio device=\(directAudioInput.deviceID) " + | ||
| "frames=\(directAudioInput.hardwareBufferFrameSize) callbackMs=\(callbackMs)" | ||
| ) | ||
| // The direct path bound whatever resolution returned — the preferred device, or the | ||
| // macOS default when it was unavailable. Announce the latter. | ||
| self.announcePreferredMicrophoneFallbackIfNeeded(recording: self.resolvedInputDeviceForCapture()) | ||
| return | ||
| } catch { | ||
| DebugLogger.shared.warning( | ||
|
|
@@ -846,9 +859,50 @@ final class ASRService: ObservableObject { | |
| self.directAudioInput = nil | ||
| } | ||
|
|
||
| // The AVAudioEngine path can only record the current macOS default input — it cannot bind an | ||
| // arbitrary device for this app alone (`kAudioUnitErr_InvalidPropertyValue`, -10851, on | ||
| // aggregate and Bluetooth devices). In FluidVoice-only mode the default is the correct | ||
| // target here: either the preferred device was unavailable and resolved to the default, or | ||
| // the direct path could not bind it on this hardware and the default is the best capture we | ||
| // have. Record it rather than refuse. | ||
| try await self.startCompatibilityAudioCapture( | ||
| reason: directCaptureEnabled ? "direct_unavailable" : "experimental_disabled" | ||
| ) | ||
| // Announce the substitution only after the capture has actually started, so a failed start | ||
| // never leaves a misleading "recording through …" notification behind. This backend always | ||
| // records the macOS default, so pass it explicitly rather than the resolved preference. (The | ||
| // direct-capture site above announces after its `start()` for the same reason.) | ||
| self.announcePreferredMicrophoneFallbackIfNeeded(recording: AudioDevice.getDefaultInputDevice()) | ||
| } | ||
|
|
||
| /// FluidVoice-only mode prefers one microphone but does not require it. When capture lands on a | ||
| /// device other than the pinned one — the preferred mic is unplugged, or the direct path cannot | ||
| /// bind it on this hardware — it records the macOS default through the private path, without ever | ||
| /// moving the system input. That substitution is invisible to the audio pipeline, so announce it | ||
| /// once per distinct fallback device; the record resets whenever the preferred device is used | ||
| /// again (`device.uid == preferredUID`), so a later fallback announces afresh. | ||
| /// | ||
| /// `device` is the input actually being recorded, passed by the caller because it differs by | ||
| /// backend: the direct path binds the resolved device, while AVAudioEngine always records the | ||
| /// macOS default regardless of the preference. | ||
| private func announcePreferredMicrophoneFallbackIfNeeded(recording device: AudioDevice.Device?) { | ||
| guard SettingsStore.shared.microphoneSelectionMode == .fluidVoiceOnly, | ||
| let preferredUID = SettingsStore.shared.preferredInputDeviceUID, | ||
| preferredUID.isEmpty == false, | ||
| let device, | ||
| device.uid != preferredUID | ||
| else { | ||
| self.lastAnnouncedFallbackDeviceUID = nil | ||
| return | ||
| } | ||
| guard self.lastAnnouncedFallbackDeviceUID != device.uid else { return } | ||
| self.lastAnnouncedFallbackDeviceUID = device.uid | ||
|
|
||
| DebugLogger.shared.info( | ||
| "Preferred microphone unavailable; recording through fallback '\(device.name)'", | ||
| source: "ASRService" | ||
| ) | ||
| NotificationService.showPreferredMicrophoneFallback(fallbackDeviceName: device.name) | ||
| } | ||
|
|
||
| private func startCompatibilityAudioCapture(reason: String) async throws { | ||
|
|
@@ -1950,8 +2004,11 @@ final class ASRService: ObservableObject { | |
| 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. | ||
| /// In `.manual` mode, attempt to bind AVAudioEngine's input to the user's preferred input device. | ||
| /// In `.system` mode we intentionally do nothing so the engine follows macOS defaults. | ||
| /// `.fluidVoiceOnly` also does nothing here: if it has reached the AVAudioEngine path at all, the | ||
| /// preferred device could not be captured directly, so recording the macOS default is the | ||
| /// intended fallback (announced by `announcePreferredMicrophoneFallbackIfNeeded()`). | ||
| /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. | ||
| @discardableResult | ||
| private func bindPreferredInputDeviceIfNeeded() -> Bool { | ||
|
|
@@ -1988,8 +2045,8 @@ final class ASRService: ObservableObject { | |
| return true | ||
|
Comment on lines
2014
to
2045
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When sync is disabled but Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is now the intended, announced behaviour. When the direct path can't bind the preferred device (e.g. a multi-stream aggregate, or a device that fails to start), FluidVoice-only records the macOS default via AVAudioEngine — "prefer, else default" — and Verified live by forcing the reject with a purpose-built 2-stream aggregate as the preferred device: the direct path returns |
||
| } | ||
|
|
||
| /// 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. | ||
| /// Output always follows the macOS default: per-app selection is input-only, so there is no | ||
| /// preferred output device to bind here. | ||
| /// Returns true if binding succeeded or if no binding was needed, false if binding failed completely. | ||
| @discardableResult | ||
| private func bindPreferredOutputDeviceIfNeeded() -> Bool { | ||
|
|
@@ -2546,14 +2603,25 @@ final class ASRService: ObservableObject { | |
| } | ||
|
|
||
| private func handleDefaultInputChanged() { | ||
| if SettingsStore.shared.microphoneSelectionMode == .manual { | ||
| switch SettingsStore.shared.microphoneSelectionMode { | ||
| case .manual: | ||
| if self.isRunning { | ||
| AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( | ||
| reason: "default input changed" | ||
| ) | ||
| self.scheduleAudioRouteRecovery(reason: "manual preferred input reasserted") | ||
| } | ||
| return | ||
| case .fluidVoiceOnly: | ||
| // The macOS default moved, but FluidVoice-only capture neither follows it nor pushes it | ||
| // back: the device we are bound to is unaffected, so there is nothing to recover. | ||
| DebugLogger.shared.info( | ||
| "Default input changed; FluidVoice-only capture keeps its own device", | ||
| source: "ASRService" | ||
| ) | ||
| return | ||
| case .system: | ||
| break | ||
| } | ||
|
|
||
| self.scheduleAudioRouteRecovery(reason: "default input changed") | ||
|
|
@@ -2765,13 +2833,28 @@ final class ASRService: ObservableObject { | |
|
|
||
| DebugLogger.shared.debug("Current input devices: \(currentDevices.map { $0.name }.joined(separator: ", "))", source: "ASRService") | ||
|
|
||
| if self.isRunning, | ||
| SettingsStore.shared.microphoneSelectionMode == .manual, | ||
| Set(currentDevices.map(\.uid)) != cachedUIDs | ||
| { | ||
| AppServices.shared.microphonePreferenceCoordinator.stabilizePreferredInputAfterHardwareChange( | ||
| reason: "input device list changed" | ||
| ) | ||
| if self.isRunning, Set(currentDevices.map(\.uid)) != cachedUIDs { | ||
| switch SettingsStore.shared.microphoneSelectionMode { | ||
| case .manual: | ||
| AppServices.shared.microphonePreferenceCoordinator | ||
| .stabilizePreferredInputAfterHardwareChange(reason: "input device list changed") | ||
| case .fluidVoiceOnly: | ||
| // A device-list change never interrupts an in-progress FluidVoice-only | ||
| // recording. Direct capture is bound to one device by ID and is unaffected by | ||
| // unrelated hardware coming or going, so reacting here would needlessly halt | ||
| // the pipeline (missed audio, gap in transcription) on any unrelated USB or | ||
| // Bluetooth event. If the *bound* device itself disconnects, the per-device | ||
| // availability monitor (handleDeviceAvailabilityChanged) recovers it | ||
| // separately; a changed device preference is applied on the next recording, | ||
| // which re-resolves. So leave the running capture alone. | ||
| DebugLogger.shared.debug( | ||
| "Input device list changed during FluidVoice-only recording; " + | ||
| "leaving direct capture undisturbed (re-resolves on next recording)", | ||
| source: "ASRService" | ||
| ) | ||
| case .system: | ||
| break | ||
| } | ||
| } | ||
|
|
||
| self.cacheCurrentDeviceList(currentDevices) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in the current diff is that the new preferred-mic notification is only invoked after
startPreferredAudioCapture()startup fallbacks;handleDirectCaptureDurationMismatch()still switches an active direct session withstartCompatibilityAudioCapture(reason: "duration_mismatch")and never calls this helper. When.fluidVoiceOnlyis recording the preferred mic and the direct path detects a duration mismatch, it falls back to AVAudioEngine/default input silently aside from the generic audio-capture-path notification, so users are not told their selected mic is no longer being used.Useful? React with 👍 / 👎.