From 42b9fb5daad038cc6012f9ffa9eb90729c3ddca2 Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Sat, 28 Mar 2026 14:54:24 +0530 Subject: [PATCH] Add input device switching to recorder widget --- .../Services/AudioRecordingService.swift | 15 +++- .../Views/Overlays/MiniRecorderView.swift | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index a164039..a398a5a 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -141,8 +141,19 @@ class AudioRecordingService: NSObject, ObservableObject { self.availableDevices = discoverySession.devices.filter { device in !device.localizedName.localizedCaseInsensitiveContains("Microsoft Teams") } - if self.selectedDeviceId == nil, let first = self.availableDevices.first { - self.selectedDeviceId = first.uniqueID + if let selectedDeviceId = self.selectedDeviceId, + self.availableDevices.contains(where: { $0.uniqueID == selectedDeviceId }) + { + return + } + + if let first = self.availableDevices.first { + if self.selectedDeviceId != first.uniqueID { + print("🎤 Falling back to available input device: \(first.localizedName)") + self.selectedDeviceId = first.uniqueID + } + } else { + self.selectedDeviceId = nil } } } diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index a006456..5083783 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -66,6 +66,21 @@ struct MiniRecorderView: View { "Spoken language hint: \(spokenLanguageDisplayName(for: transcriptionLanguage)). If this does not match the language you actually speak, the result may be inaccurate or come back in the wrong language." } + private var currentInputDeviceName: String { + guard + let selectedDeviceId = audioRecorder.selectedDeviceId, + let device = audioRecorder.availableDevices.first(where: { $0.uniqueID == selectedDeviceId }) + else { + return "No input selected" + } + + return device.localizedName + } + + private var inputDeviceHelpText: String { + "Input device: \(currentInputDeviceName). Change microphones without going back to Settings." + } + private var isAccessibilityEnabled: Bool { AXIsProcessTrusted() } @@ -180,6 +195,46 @@ struct MiniRecorderView: View { .fixedSize() .help(spokenLanguageHelpText) + Menu { + if audioRecorder.availableDevices.isEmpty { + Button("No input devices found") {} + .disabled(true) + } else { + ForEach(audioRecorder.availableDevices, id: \.uniqueID) { device in + Button { + selectAudioDevice(device.uniqueID) + } label: { + if audioRecorder.selectedDeviceId == device.uniqueID { + Label(device.localizedName, systemImage: "checkmark") + } else { + Text(device.localizedName) + } + } + } + } + + Divider() + Button("Refresh inputs") { + audioRecorder.fetchAvailableDevices() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "mic.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.92)) + + DoubleChevronIcon(color: .white.opacity(0.92)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .menuIndicator(.hidden) + .menuStyle(.borderlessButton) + .fixedSize() + .help(inputDeviceHelpText) + // Recording mode indicator Image(systemName: recordingMode == 0 ? "hand.tap.fill" : "repeat.1") .font(.system(size: 14, weight: .semibold)) @@ -208,6 +263,7 @@ struct MiniRecorderView: View { } .onAppear { initializedService() + audioRecorder.fetchAvailableDevices() // Set up Escape key monitors globalEscapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in @@ -436,6 +492,37 @@ struct MiniRecorderView: View { isListening = true } + private func selectAudioDevice(_ deviceId: String) { + guard audioRecorder.selectedDeviceId != deviceId else { return } + + let shouldResumeRecording = isListening + + Task { + if shouldResumeRecording { + await MainActor.run { + isListening = false + isProcessing = true + statusMessage = "Switching input..." + } + + _ = await audioRecorder.stopRecording(discardOutput: true) + } + + await MainActor.run { + audioRecorder.selectedDeviceId = deviceId + } + + guard shouldResumeRecording else { return } + + audioRecorder.startRecording() + + await MainActor.run { + isProcessing = false + isListening = true + } + } + } + private func stopAndTranscribe() { debugLog("stopAndTranscribe called")