diff --git a/.changeset/83606f78.md b/.changeset/83606f78.md new file mode 100644 index 0000000..bb41345 --- /dev/null +++ b/.changeset/83606f78.md @@ -0,0 +1,5 @@ +--- +"hex-app": patch +--- + +Refresh microphones reliably, harden recording cleanup, and polish the settings overlay behavior. diff --git a/Hex.xcodeproj/project.pbxproj b/Hex.xcodeproj/project.pbxproj index ad9137f..ef7525c 100644 --- a/Hex.xcodeproj/project.pbxproj +++ b/Hex.xcodeproj/project.pbxproj @@ -298,7 +298,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hex.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hex"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hex Debug.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hex Debug"; }; name = Debug; }; diff --git a/Hex/App/HexApp.swift b/Hex/App/HexApp.swift index 8609f2e..d20d631 100644 --- a/Hex/App/HexApp.swift +++ b/Hex/App/HexApp.swift @@ -14,34 +14,36 @@ struct HexApp: App { var body: some Scene { MenuBarExtra { - CheckForUpdatesView() - - // Copy last transcript to clipboard MenuBarCopyLastTranscriptButton() - Button("Settings...") { + Button("Settings…") { appDelegate.presentSettingsView() }.keyboardShortcut(",") + + CheckForUpdatesView() Divider() - Button("Quit") { + Button("Quit Hex") { NSApplication.shared.terminate(nil) }.keyboardShortcut("q") } label: { - let image: NSImage = { + if let image = NSImage(named: "HexIcon").map({ let ratio = $0.size.height / $0.size.width $0.size.height = 18 $0.size.width = 18 / ratio return $0 - }(NSImage(named: "HexIcon")!) - Image(nsImage: image) + }) { + Image(nsImage: image) + } else { + Image(systemName: "hexagon") + } } .commands { CommandGroup(after: .appInfo) { CheckForUpdatesView() - Button("Settings...") { + Button("Settings…") { appDelegate.presentSettingsView() }.keyboardShortcut(",") } diff --git a/Hex/App/HexAppDelegate.swift b/Hex/App/HexAppDelegate.swift index e0df1e9..06139f3 100644 --- a/Hex/App/HexAppDelegate.swift +++ b/Hex/App/HexAppDelegate.swift @@ -102,7 +102,7 @@ class HexAppDelegate: NSObject, NSApplicationDelegate { let transcriptionView = TranscriptionView(store: transcriptionStore).padding().padding(.top).padding(.top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) invisibleWindow = InvisibleWindow.fromView(transcriptionView) - invisibleWindow?.makeKeyAndOrderFront(nil) + invisibleWindow?.orderFrontRegardless() } func presentSettingsView() { @@ -115,13 +115,15 @@ class HexAppDelegate: NSObject, NSApplicationDelegate { let settingsView = AppView(store: HexApp.appStore) let settingsWindow = NSWindow( contentRect: .init(x: 0, y: 0, width: 700, height: 700), - styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable], + styleMask: [.titled, .fullSizeContentView, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) settingsWindow.titleVisibility = .visible settingsWindow.contentView = NSHostingView(rootView: settingsView) settingsWindow.isReleasedWhenClosed = false + settingsWindow.minSize = .init(width: 620, height: 560) + settingsWindow.setFrameAutosaveName("Settings") settingsWindow.center() settingsWindow.toolbarStyle = NSWindow.ToolbarStyle.unified settingsWindow.makeKeyAndOrderFront(nil) diff --git a/Hex/App/MenuBarCopyLastTranscriptButton.swift b/Hex/App/MenuBarCopyLastTranscriptButton.swift index 84c6a31..4f4f5ef 100644 --- a/Hex/App/MenuBarCopyLastTranscriptButton.swift +++ b/Hex/App/MenuBarCopyLastTranscriptButton.swift @@ -12,24 +12,11 @@ struct MenuBarCopyLastTranscriptButton: View { var body: some View { let lastText = transcriptionHistory.history.first?.text - let preview: String = { - guard let text = lastText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return "" } - let snippet = text.prefix(40) - return "\(snippet)\(text.count > 40 ? "…" : "")" - }() - let button = Button(action: { + let button = Button("Paste Last Transcript") { if let text = lastText { Task { await pasteboard.paste(text) } } - }) { - HStack(spacing: 6) { - Text("Paste Last Transcript") - if !preview.isEmpty { - Text("(\(preview))") - .foregroundStyle(.secondary) - } - } } .disabled(lastText == nil) diff --git a/Hex/Clients/RecordingClient.swift b/Hex/Clients/RecordingClient.swift index d1e62fd..67d7f92 100644 --- a/Hex/Clients/RecordingClient.swift +++ b/Hex/Clients/RecordingClient.swift @@ -22,6 +22,7 @@ private typealias CoreAudioPropertyListenerBlock = @convention(block) (UInt32, U struct AudioInputDevice: Identifiable, Equatable { var id: String var name: String + var legacyID: String } @DependencyClient @@ -375,40 +376,18 @@ actor RecordingClientLive { /// Tracks previous system volume when muted for recording private var previousVolume: Float? - // Cache to store already-processed device information - private var deviceCache: [AudioDeviceID: (hasInput: Bool, name: String?)] = [:] - private var lastDeviceCheck = Date(timeIntervalSince1970: 0) - /// Gets all available input devices on the system func getAvailableInputDevices() async -> [AudioInputDevice] { - // Reset cache if it's been more than 5 minutes since last full refresh - let now = Date() - if now.timeIntervalSince(lastDeviceCheck) > 300 { - deviceCache.removeAll() - lastDeviceCheck = now - } - // Get all available audio devices let devices = getAllAudioDevices() var inputDevices: [AudioInputDevice] = [] // Filter to only input devices and convert to our model for device in devices { - let hasInput: Bool - let name: String? - - // Check cache first to avoid expensive Core Audio calls - if let cached = deviceCache[device] { - hasInput = cached.hasInput - name = cached.name - } else { - hasInput = deviceHasInput(deviceID: device) - name = hasInput ? getDeviceName(deviceID: device) : nil - deviceCache[device] = (hasInput, name) - } - - if hasInput, let deviceName = name { - inputDevices.append(AudioInputDevice(id: String(device), name: deviceName)) + if deviceHasInput(deviceID: device), + let deviceUID = getDeviceUID(deviceID: device), + let deviceName = getDeviceName(deviceID: device) { + inputDevices.append(AudioInputDevice(id: deviceUID, name: deviceName, legacyID: String(device))) } } @@ -418,14 +397,7 @@ actor RecordingClientLive { /// Gets the current system default input device name func getDefaultInputDeviceName() async -> String? { guard let deviceID = getDefaultInputDevice() else { return nil } - if let cached = deviceCache[deviceID], cached.hasInput, let name = cached.name { - return name - } - let name = getDeviceName(deviceID: deviceID) - if let name { - deviceCache[deviceID] = (hasInput: true, name: name) - } - return name + return getDeviceName(deviceID: deviceID) } // MARK: - Core Audio Helpers @@ -668,7 +640,19 @@ actor RecordingClientLive { /// Get device name for the given device ID private func getDeviceName(deviceID: AudioDeviceID) -> String? { - var address = audioPropertyAddress(kAudioDevicePropertyDeviceNameCFString) + getDeviceStringProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceNameCFString) + } + + /// Get the persistent device UID for the given device ID + private func getDeviceUID(deviceID: AudioDeviceID) -> String? { + getDeviceStringProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceUID) + } + + private func getDeviceStringProperty( + deviceID: AudioDeviceID, + selector: AudioObjectPropertySelector + ) -> String? { + var address = audioPropertyAddress(selector) var deviceName: CFString? = nil var size = UInt32(MemoryLayout.size) @@ -689,7 +673,7 @@ actor RecordingClientLive { } if status != 0 { - recordingLogger.error("Failed to fetch device name: \(status)") + recordingLogger.error("Failed to fetch device property \(selector): \(status)") return nil } @@ -786,20 +770,41 @@ actor RecordingClientLive { } private func resolvePreferredInputDevice() -> AudioDeviceID? { - if let selectedDeviceIDString = hexSettings.selectedMicrophoneID, - let selectedDeviceID = AudioDeviceID(selectedDeviceIDString) { - let devices = getAllAudioDevices() - if devices.contains(selectedDeviceID), deviceHasInput(deviceID: selectedDeviceID) { - return selectedDeviceID - } - - recordingLogger.notice("Selected device \(selectedDeviceID) missing; using system default") - return nil + guard let selectedMicrophoneID = hexSettings.selectedMicrophoneID else { return nil } + if let deviceID = getDeviceID(uid: selectedMicrophoneID), + deviceHasInput(deviceID: deviceID) { + return deviceID } + if let legacyDeviceID = AudioDeviceID(selectedMicrophoneID), + deviceHasInput(deviceID: legacyDeviceID) { + return legacyDeviceID + } + + recordingLogger.notice("Selected device \(selectedMicrophoneID) missing; using system default") return nil } + private func getDeviceID(uid: String) -> AudioDeviceID? { + var address = audioPropertyAddress(kAudioHardwarePropertyDeviceForUID) + var deviceUID = uid as CFString + var deviceID = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + let status = withUnsafePointer(to: &deviceUID) { pointer in + AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + UInt32(MemoryLayout.size), + pointer, + &size, + &deviceID + ) + } + + guard status == noErr, deviceID != kAudioObjectUnknown else { return nil } + return deviceID + } + private func formatDuration(_ duration: TimeInterval?) -> String { guard let duration else { return "n/a" } return String(format: "%.3fs", duration) @@ -936,25 +941,10 @@ actor RecordingClientLive { /// Checks and fixes muted input device before recording private func ensureInputDeviceUnmuted() { - // Check the selected device if specified, otherwise the default - var deviceIDsToCheck: [AudioDeviceID] = [] - - if let selectedIDString = hexSettings.selectedMicrophoneID, - let selectedID = AudioDeviceID(selectedIDString) { - deviceIDsToCheck.append(selectedID) - } - - if let defaultID = getDefaultInputDevice() { - if !deviceIDsToCheck.contains(defaultID) { - deviceIDsToCheck.append(defaultID) - } - } - - for deviceID in deviceIDsToCheck { - if isInputDeviceMuted(deviceID) { - recordingLogger.error("⚠️ Input device \(deviceID) is MUTED at Core Audio level! This causes silent recordings.") - unmuteInputDevice(deviceID) - } + guard let deviceID = getDefaultInputDevice() else { return } + if isInputDeviceMuted(deviceID) { + recordingLogger.error("⚠️ Input device \(deviceID) is MUTED at Core Audio level! This causes silent recordings.") + unmuteInputDevice(deviceID) } } @@ -1049,9 +1039,6 @@ actor RecordingClientLive { } func startRecording() async { - // Check and fix device-level mute before recording - ensureInputDeviceUnmuted() - let sessionID = UUID() recordingSessionID = sessionID mediaControlTask?.cancel() @@ -1069,12 +1056,17 @@ actor RecordingClientLive { // First, pause all media applications using their AppleScript interface. let paused = await pauseAllMediaApplications() + guard await self.isCurrentSession(sessionID) else { + await resumeMediaApplications(paused) + return + } await self.updatePausedPlayers(paused, sessionID: sessionID) // If no specific players were paused, pause generic media using the media key. guard await self.isCurrentSession(sessionID) else { return } if paused.isEmpty { if await isAudioPlayingOnDefaultOutput() { + guard await self.isCurrentSession(sessionID) else { return } mediaLogger.notice("Detected active audio on default output; sending media pause") await MainActor.run { sendMediaKey() @@ -1092,6 +1084,10 @@ actor RecordingClientLive { mediaControlTask = Task { [sessionID] in guard await self.isCurrentSession(sessionID) else { return } let volume = await self.muteSystemVolume() + guard await self.isCurrentSession(sessionID) else { + await self.restoreSystemVolume(volume) + return + } await self.setPreviousVolume(volume, sessionID: sessionID) } @@ -1101,6 +1097,8 @@ actor RecordingClientLive { } let activeInputDevice = applyPreferredInputDevice() + // Check the actual active device after applying the user's preferred input. + ensureInputDeviceUnmuted() let mode = currentCaptureMode() logRecordingStartRequest(mode: mode, inputDeviceID: activeInputDevice) let startRequestAt = Date() @@ -1203,16 +1201,22 @@ actor RecordingClientLive { ) let recordingDuration = stoppedAt.timeIntervalSince(session.startedAt) let wasRecording = recorder?.isRecording == true + guard session.backend == .recorderFallback, wasRecording else { + recordingLogger.notice("stopRecording() called without an active recorder fallback; skipping stale recording.wav export") + stopMeterTask() + endRecordingSession() + clearActiveRecordingMetadata() + lastRecordingEndedAt = stoppedAt + await flushDeferredCaptureRestartIfNeeded() + await resumeMediaIfNeeded() + return makeIgnoredStopURL() + } recorder?.stop() stopMeterTask() endRecordingSession() clearActiveRecordingMetadata() lastRecordingEndedAt = stoppedAt - if wasRecording { - recordingLogger.notice("Recording stopped mode=\(session.mode.rawValue) backend=\(session.backend.rawValue) duration=\(self.formatDuration(recordingDuration))") - } else { - recordingLogger.notice("stopRecording() called while recorder was idle") - } + recordingLogger.notice("Recording stopped mode=\(session.mode.rawValue) backend=\(session.backend.rawValue) duration=\(self.formatDuration(recordingDuration))") var exportedURL = recordingURL var didCopyRecording = false @@ -1226,9 +1230,7 @@ actor RecordingClientLive { if didCopyRecording { do { - if session.backend == .recorderFallback { - try primeRecorderForNextSession() - } + try primeRecorderForNextSession() } catch { isRecorderPrimedForNextSession = false recordingLogger.error("Failed to prime recorder fallback: \(error.localizedDescription)") @@ -1246,45 +1248,39 @@ actor RecordingClientLive { } private func resumeMediaIfNeeded() async { - // Resume audio in background - don't block stop from completing let playersToResume = pausedPlayers let shouldResumeMedia = didPauseMedia let shouldResumeViaMediaRemote = didPauseViaMediaRemote let volumeToRestore = previousVolume - if !playersToResume.isEmpty || shouldResumeMedia || shouldResumeViaMediaRemote || volumeToRestore != nil { - Task { - // Restore volume if it was muted - if let volume = volumeToRestore { - await self.restoreSystemVolume(volume) - } - // Resume media if we previously paused specific players - else if !playersToResume.isEmpty { - mediaLogger.notice("Resuming players: \(playersToResume.joined(separator: ", "))") - await resumeMediaApplications(playersToResume) - } - else if shouldResumeViaMediaRemote { - if mediaRemoteController?.send(.play) == true { - mediaLogger.notice("Resuming media via MediaRemote") - } else { - mediaLogger.error("Failed to resume via MediaRemote; falling back to media key") - await MainActor.run { - sendMediaKey() - } - } - } - // Resume generic media if we paused it with the media key - else if shouldResumeMedia { - await MainActor.run { - sendMediaKey() - } - mediaLogger.notice("Resuming media via media key") - } + clearMediaState() - // Clear the flags - self.clearMediaState() + // Restore volume if it was muted + if let volume = volumeToRestore { + await restoreSystemVolume(volume) + } + // Resume media if we previously paused specific players + else if !playersToResume.isEmpty { + mediaLogger.notice("Resuming players: \(playersToResume.joined(separator: ", "))") + await resumeMediaApplications(playersToResume) + } + else if shouldResumeViaMediaRemote { + if mediaRemoteController?.send(.play) == true { + mediaLogger.notice("Resuming media via MediaRemote") + } else { + mediaLogger.error("Failed to resume via MediaRemote; falling back to media key") + await MainActor.run { + sendMediaKey() + } } } + // Resume generic media if we paused it with the media key + else if shouldResumeMedia { + await MainActor.run { + sendMediaKey() + } + mediaLogger.notice("Resuming media via media key") + } } // Actor state update helpers @@ -1344,6 +1340,7 @@ actor RecordingClientLive { guard isPlaying else { return false } + guard isCurrentSession(sessionID), !Task.isCancelled else { return false } guard controller.send(.pause) else { mediaLogger.error("Failed to send MediaRemote pause command") @@ -1443,6 +1440,10 @@ actor RecordingClientLive { } func warmUpRecorder() async { + guard activeRecordingSession == nil, recorder?.isRecording != true, !captureController.isRecording else { + recordingLogger.notice("Skipping recorder warm-up while recording is active") + return + } let activeInputDevice = applyPreferredInputDevice() if hexSettings.superFastModeEnabled { @@ -1461,8 +1462,9 @@ actor RecordingClientLive { } /// Release recorder resources. Call on app termination. - func cleanup() { + func cleanup() async { endRecordingSession() + await resumeMediaIfNeeded() stopObservingSystemChanges() stopCaptureController(reason: "cleanup") releaseRecorder(reason: "cleanup") diff --git a/Hex/Features/App/AppFeature.swift b/Hex/Features/App/AppFeature.swift index 0183bf2..82b6b1e 100644 --- a/Hex/Features/App/AppFeature.swift +++ b/Hex/Features/App/AppFeature.swift @@ -48,9 +48,6 @@ struct AppFeature { case checkPermissions case permissionsUpdated(mic: PermissionStatus, acc: PermissionStatus, input: PermissionStatus) case appActivated - case requestMicrophone - case requestAccessibility - case requestInputMonitoring case modelStatusEvaluated(Bool) } @@ -111,6 +108,31 @@ struct AppFeature { case .transcription: return .none + case .settings(.requestMicrophone): + return .run { send in + _ = await permissions.requestMicrophone() + await send(.checkPermissions) + } + + case .settings(.requestAccessibility): + return .run { send in + await permissions.requestAccessibility() + // Poll for status change (macOS doesn't provide callback) + for _ in 0..<10 { + try? await Task.sleep(for: .seconds(1)) + await send(.checkPermissions) + } + } + + case .settings(.requestInputMonitoring): + return .run { send in + _ = await permissions.requestInputMonitoring() + for _ in 0..<10 { + try? await Task.sleep(for: .seconds(1)) + await send(.checkPermissions) + } + } + case .settings: return .none @@ -142,31 +164,6 @@ struct AppFeature { // App became active - re-check permissions return .send(.checkPermissions) - case .requestMicrophone: - return .run { send in - _ = await permissions.requestMicrophone() - await send(.checkPermissions) - } - - case .requestAccessibility: - return .run { send in - await permissions.requestAccessibility() - // Poll for status change (macOS doesn't provide callback) - for _ in 0..<10 { - try? await Task.sleep(for: .seconds(1)) - await send(.checkPermissions) - } - } - - case .requestInputMonitoring: - return .run { send in - _ = await permissions.requestInputMonitoring() - for _ in 0..<10 { - try? await Task.sleep(for: .seconds(1)) - await send(.checkPermissions) - } - } - case .modelStatusEvaluated: return .none } diff --git a/Hex/Features/History/HistoryFeature.swift b/Hex/Features/History/HistoryFeature.swift index 72ef1a5..9950d94 100644 --- a/Hex/Features/History/HistoryFeature.swift +++ b/Hex/Features/History/HistoryFeature.swift @@ -57,27 +57,34 @@ extension URL { class AudioPlayerController: NSObject, AVAudioPlayerDelegate { private var player: AVAudioPlayer? - var onPlaybackFinished: (() -> Void)? + private let (playbackFinishedStream, playbackFinishedContinuation) = AsyncStream.makeStream() - func play(url: URL) throws -> AVAudioPlayer { + func play(url: URL) throws { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self - player.play() self.player = player - return player + player.play() } func stop() { player?.stop() player = nil + finishPlayback() + } + + func waitForPlaybackToFinish() async { + for await _ in playbackFinishedStream {} } // AVAudioPlayerDelegate method func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + guard self.player === player else { return } self.player = nil - Task { @MainActor in - onPlaybackFinished?() - } + finishPlayback() + } + + private func finishPlayback() { + playbackFinishedContinuation.finish() } } @@ -89,14 +96,14 @@ struct HistoryFeature { struct State: Equatable { @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory var playingTranscriptID: UUID? - var audioPlayer: AVAudioPlayer? + var playbackID: UUID? var audioPlayerController: AudioPlayerController? mutating func stopAudioPlayback() { audioPlayerController?.stop() - audioPlayer = nil audioPlayerController = nil playingTranscriptID = nil + playbackID = nil } } @@ -107,7 +114,7 @@ struct HistoryFeature { case deleteTranscript(UUID) case deleteAllTranscripts case confirmDeleteAll - case playbackFinished + case playbackFinished(UUID) case navigateToSettings } @@ -142,31 +149,28 @@ struct HistoryFeature { do { let controller = AudioPlayerController() - let player = try controller.play(url: transcript.audioPath) + try controller.play(url: transcript.audioPath) + let playbackID = UUID() - state.audioPlayer = player state.audioPlayerController = controller state.playingTranscriptID = id + state.playbackID = playbackID return .run { send in - // Using non-throwing continuation since we don't need to throw errors - await withCheckedContinuation { continuation in - controller.onPlaybackFinished = { - continuation.resume() - - // Use Task to switch to MainActor for sending the action - Task { @MainActor in - send(.playbackFinished) - } - } - } + await controller.waitForPlaybackToFinish() + await send(.playbackFinished(playbackID)) } } catch { historyLogger.error("Failed to play audio: \(error.localizedDescription)") return .none } - case .stopPlayback, .playbackFinished: + case .stopPlayback: + state.stopAudioPlayback() + return .none + + case let .playbackFinished(playbackID): + guard state.playbackID == playbackID else { return .none } state.stopAudioPlayback() return .none diff --git a/Hex/Features/Settings/SettingsFeature.swift b/Hex/Features/Settings/SettingsFeature.swift index b633cc3..340b705 100644 --- a/Hex/Features/Settings/SettingsFeature.swift +++ b/Hex/Features/Settings/SettingsFeature.swift @@ -116,10 +116,8 @@ struct SettingsFeature { } @Dependency(\.keyEventMonitor) var keyEventMonitor - @Dependency(\.continuousClock) var clock @Dependency(\.transcription) var transcription @Dependency(\.recording) var recording - @Dependency(\.permissions) var permissions @Dependency(\.soundEffects) var soundEffects @Dependency(\.transcriptPersistence) var transcriptPersistence @@ -259,17 +257,6 @@ struct SettingsFeature { await send(.modelDownload(.fetchModels)) await send(.loadAvailableInputDevices) - // Set up periodic refresh of available devices (every 120 seconds) - // Using a longer interval to reduce resource usage - let deviceRefreshTask = Task { @MainActor in - for await _ in clock.timer(interval: .seconds(120)) { - // Only refresh when the app is active to save resources - if NSApplication.shared.isActive { - await send(.loadAvailableInputDevices) - } - } - } - // Listen for device connection/disconnection notifications // Using a simpler debounced approach with a single task var deviceUpdateTask: Task? @@ -366,7 +353,6 @@ struct SettingsFeature { await send(.keyEvent(keyEvent)) } - deviceRefreshTask.cancel() } case .startSettingHotKey: @@ -496,7 +482,9 @@ struct SettingsFeature { case let .setSelectedMicrophoneID(deviceID): state.$hexSettings.withLock { $0.selectedMicrophoneID = deviceID } - return .none + return .run { _ in + await recording.warmUpRecorder() + } case let .setSoundEffectsEnabled(enabled): state.$hexSettings.withLock { $0.soundEffectsEnabled = enabled } @@ -511,31 +499,17 @@ struct SettingsFeature { // Permission requests case .requestMicrophone: settingsLogger.info("User requested microphone permission from settings") - return .run { _ in - _ = await permissions.requestMicrophone() - } + return .none case .requestAccessibility: settingsLogger.info("User requested accessibility permission from settings") - return .run { _ in - await permissions.requestAccessibility() - } + return .none case .requestInputMonitoring: settingsLogger.info("User requested input monitoring permission from settings") - return .run { _ in - _ = await permissions.requestInputMonitoring() - } - - // Model Management - case let .modelDownload(.selectModel(newModel)): - // Also store it in hexSettings: - state.$hexSettings.withLock { - $0.selectedModel = newModel - } - // Then continue with the child's normal logic: return .none + // Model Management case .modelDownload: return .none @@ -548,6 +522,12 @@ struct SettingsFeature { } case let .availableInputDevicesLoaded(devices, defaultName): + if let selectedMicrophoneID = state.hexSettings.selectedMicrophoneID, + let device = devices.first(where: { $0.legacyID == selectedMicrophoneID }) { + state.availableInputDevices = devices + state.defaultInputDeviceName = defaultName + return .send(.setSelectedMicrophoneID(device.id)) + } state.availableInputDevices = devices state.defaultInputDeviceName = defaultName return .none diff --git a/Hex/Features/Transcription/TranscriptionFeature.swift b/Hex/Features/Transcription/TranscriptionFeature.swift index e22b747..ea8cba4 100644 --- a/Hex/Features/Transcription/TranscriptionFeature.swift +++ b/Hex/Features/Transcription/TranscriptionFeature.swift @@ -50,7 +50,7 @@ struct TranscriptionFeature { case discard // Silent discard (too short/accidental) // Transcription result flow - case transcriptionResult(String, URL) + case transcriptionResult(String, URL, TimeInterval) case transcriptionError(Error, URL?) // Model availability @@ -59,6 +59,7 @@ struct TranscriptionFeature { enum CancelID { case metering + case recordingStart case recordingCleanup case transcription } @@ -116,8 +117,8 @@ struct TranscriptionFeature { // MARK: - Transcription Results - case let .transcriptionResult(result, audioURL): - return handleTranscriptionResult(&state, result: result, audioURL: audioURL) + case let .transcriptionResult(result, audioURL, duration): + return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration) case let .transcriptionError(error, audioURL): return handleTranscriptionError(&state, error: error, audioURL: audioURL) @@ -264,9 +265,11 @@ private extension TranscriptionFeature { private extension TranscriptionFeature { func handleHotKeyPressed(isTranscribing: Bool) -> Effect { // If already transcribing, cancel first. Otherwise start recording immediately. - let maybeCancel = isTranscribing ? Effect.send(Action.cancel) : .none - let startRecording = Effect.send(Action.startRecording) - return .merge(maybeCancel, startRecording) + guard isTranscribing else { return .send(.startRecording) } + return .concatenate( + .send(.cancel), + .send(.startRecording) + ) } func handleHotKeyReleased(isRecording: Bool) -> Effect { @@ -306,8 +309,15 @@ private extension TranscriptionFeature { if preventSleep { await sleepManagement.preventSleep(reason: "Hex Voice Recording") } + guard !Task.isCancelled else { + if preventSleep { + await sleepManagement.allowSleep() + } + return + } await recording.startRecording() } + .cancellable(id: CancelID.recordingStart, cancelInFlight: true) ) } @@ -339,12 +349,7 @@ private extension TranscriptionFeature { // If the user recorded for less than minimumKeyTime and the hotkey is modifier-only, // discard the audio to avoid accidental triggers. transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))") - return .run { _ in - let url = await recording.stopRecording() - guard !Task.isCancelled else { return } - try? FileManager.default.removeItem(at: url) - } - .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) + return handleDiscard(&state) } // Otherwise, proceed to transcription @@ -355,35 +360,44 @@ private extension TranscriptionFeature { state.isPrewarming = true - return .run { [sleepManagement] send in - // Allow system to sleep again - await sleepManagement.allowSleep() + return .merge( + .cancel(id: CancelID.recordingStart), + .run { [sleepManagement] send in + // Allow system to sleep again + await sleepManagement.allowSleep() - var audioURL: URL? - do { - let capturedURL = await recording.stopRecording() - guard !Task.isCancelled else { return } - soundEffect.play(.stopRecording) - audioURL = capturedURL + var audioURL: URL? + defer { + if let audioURL { + FileManager.default.removeItemIfExists(at: audioURL) + } + } + do { + let capturedURL = await recording.stopRecording() + audioURL = capturedURL + guard !Task.isCancelled else { return } + soundEffect.play(.stopRecording) - // Create transcription options with the selected language - // Note: cap concurrency to avoid audio I/O overloads on some Macs - let decodeOptions = DecodingOptions( - language: language, - detectLanguage: language == nil, // Only auto-detect if no language specified - chunkingStrategy: .vad, - ) - - let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in } - - transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)") - await send(.transcriptionResult(result, capturedURL)) - } catch { - transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)") - await send(.transcriptionError(error, audioURL)) + // Create transcription options with the selected language + // Note: cap concurrency to avoid audio I/O overloads on some Macs + let decodeOptions = DecodingOptions( + language: language, + detectLanguage: language == nil, // Only auto-detect if no language specified + chunkingStrategy: .vad, + ) + + let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in } + + transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)") + audioURL = nil + await send(.transcriptionResult(result, capturedURL, duration)) + } catch { + transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)") + await send(.transcriptionError(error, nil)) + } } - } - .cancellable(id: CancelID.transcription) + .cancellable(id: CancelID.transcription) + ) } } @@ -393,7 +407,8 @@ private extension TranscriptionFeature { func handleTranscriptionResult( _ state: inout State, result: String, - audioURL: URL + audioURL: URL, + duration: TimeInterval ) -> Effect { state.isTranscribing = false state.isPrewarming = false @@ -402,7 +417,7 @@ private extension TranscriptionFeature { if ForceQuitCommandDetector.matches(result) { transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.") return .run { _ in - try? FileManager.default.removeItem(at: audioURL) + FileManager.default.removeItemIfExists(at: audioURL) await MainActor.run { NSApp.terminate(nil) } @@ -411,12 +426,12 @@ private extension TranscriptionFeature { // If empty text, nothing else to do guard !result.isEmpty else { - return .none + return .run { _ in + FileManager.default.removeItemIfExists(at: audioURL) + } } - let duration = state.recordingStartTime.map { Date().timeIntervalSince($0) } ?? 0 - - transcriptionFeatureLogger.info("Raw transcription: '\(result)'") + transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'") let remappings = state.hexSettings.wordRemappings let removalsEnabled = state.hexSettings.wordRemovalsEnabled let removals = state.hexSettings.wordRemovals @@ -442,7 +457,9 @@ private extension TranscriptionFeature { } guard !modifiedResult.isEmpty else { - return .none + return .run { _ in + FileManager.default.removeItemIfExists(at: audioURL) + } } let sourceAppBundleID = state.sourceAppBundleID @@ -476,7 +493,7 @@ private extension TranscriptionFeature { state.error = error.localizedDescription if let audioURL { - try? FileManager.default.removeItem(at: audioURL) + FileManager.default.removeItemIfExists(at: audioURL) } return .none @@ -516,7 +533,7 @@ private extension TranscriptionFeature { } } } else { - try? FileManager.default.removeItem(at: audioURL) + FileManager.default.removeItemIfExists(at: audioURL) } await pasteboard.paste(result) @@ -528,19 +545,25 @@ private extension TranscriptionFeature { private extension TranscriptionFeature { func handleCancel(_ state: inout State) -> Effect { + let wasRecording = state.isRecording state.isTranscribing = false state.isRecording = false state.isPrewarming = false return .merge( .cancel(id: CancelID.transcription), + .cancel(id: CancelID.recordingStart), .run { [sleepManagement] _ in // Allow system to sleep again await sleepManagement.allowSleep() + guard wasRecording else { + soundEffect.play(.cancel) + return + } // Stop the recording to release microphone access let url = await recording.stopRecording() guard !Task.isCancelled else { return } - try? FileManager.default.removeItem(at: url) + FileManager.default.removeItemIfExists(at: url) soundEffect.play(.cancel) } .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) @@ -552,14 +575,17 @@ private extension TranscriptionFeature { state.isPrewarming = false // Silently discard - no sound effect - return .run { [sleepManagement] _ in - // Allow system to sleep again - await sleepManagement.allowSleep() - let url = await recording.stopRecording() - guard !Task.isCancelled else { return } - try? FileManager.default.removeItem(at: url) - } - .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) + return .merge( + .cancel(id: CancelID.recordingStart), + .run { [sleepManagement] _ in + // Allow system to sleep again + await sleepManagement.allowSleep() + let url = await recording.stopRecording() + guard !Task.isCancelled else { return } + FileManager.default.removeItemIfExists(at: url) + } + .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) + ) } } diff --git a/Hex/Views/InvisibleWindow.swift b/Hex/Views/InvisibleWindow.swift index 6849328..2903ef1 100644 --- a/Hex/Views/InvisibleWindow.swift +++ b/Hex/Views/InvisibleWindow.swift @@ -15,8 +15,8 @@ import SwiftUI /// I'm pretty sure this is what CleanShot X and other apps do to render their floating widgets. /// But if there's a better way to do this, I'd love to know! class InvisibleWindow: NSPanel { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } private var currentScreen: NSScreen? private var mouseMonitor: Any? @@ -34,6 +34,7 @@ class InvisibleWindow: NSPanel { backgroundColor = .clear isOpaque = false hasShadow = false + ignoresMouseEvents = true hidesOnDeactivate = false // Prevent hiding when app loses focus canHide = false collectionBehavior = [.fullScreenAuxiliary, .canJoinAllSpaces, .stationary, .ignoresCycle] diff --git a/HexCore/Sources/HexCore/Settings/HexSettings.swift b/HexCore/Sources/HexCore/Settings/HexSettings.swift index 4c301ad..4ba3049 100644 --- a/HexCore/Sources/HexCore/Settings/HexSettings.swift +++ b/HexCore/Sources/HexCore/Settings/HexSettings.swift @@ -236,7 +236,7 @@ private enum HexSettingsSchema { ).eraseToAny(), SettingsField(.minimumKeyTime, keyPath: \.minimumKeyTime, default: defaults.minimumKeyTime).eraseToAny(), SettingsField(.copyToClipboard, keyPath: \.copyToClipboard, default: defaults.copyToClipboard).eraseToAny(), - SettingsField(.superFastModeEnabled, keyPath: \.superFastModeEnabled, default: false).eraseToAny(), + SettingsField(.superFastModeEnabled, keyPath: \.superFastModeEnabled, default: defaults.superFastModeEnabled).eraseToAny(), SettingsField(.useDoubleTapOnly, keyPath: \.useDoubleTapOnly, default: defaults.useDoubleTapOnly).eraseToAny(), SettingsField(.doubleTapLockEnabled, keyPath: \.doubleTapLockEnabled, default: defaults.doubleTapLockEnabled).eraseToAny(), SettingsField( diff --git a/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift b/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift index bd0500a..6de0208 100644 --- a/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift +++ b/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift @@ -16,7 +16,7 @@ final class HexSettingsMigrationTests: XCTestCase { XCTAssertEqual(decoded.preventSystemSleep, true) XCTAssertEqual(decoded.minimumKeyTime, 0.25) XCTAssertEqual(decoded.copyToClipboard, true) - XCTAssertFalse(decoded.superFastModeEnabled) + XCTAssertTrue(decoded.superFastModeEnabled) XCTAssertEqual(decoded.useDoubleTapOnly, true) XCTAssertEqual(decoded.doubleTapLockEnabled, true) XCTAssertEqual(decoded.outputLanguage, "en") diff --git a/HexTests/HistoryPlaybackTests.swift b/HexTests/HistoryPlaybackTests.swift new file mode 100644 index 0000000..0a93613 --- /dev/null +++ b/HexTests/HistoryPlaybackTests.swift @@ -0,0 +1,44 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import Hex + +@Suite(.serialized) +@MainActor +struct HistoryPlaybackTests { + @Test + func stoppingPlaybackCompletesWaitersExactlyOnce() async { + let controller = AudioPlayerController() + let waiter = Task { + await controller.waitForPlaybackToFinish() + } + + await Task.yield() + controller.stop() + controller.stop() + + await waiter.value + await controller.waitForPlaybackToFinish() + } + + @Test + func stalePlaybackFinishedDoesNotStopCurrentPlayback() async { + let transcriptID = UUID() + let playbackID = UUID() + let store = TestStore( + initialState: HistoryFeature.State( + transcriptionHistory: Shared(.init()), + playingTranscriptID: transcriptID, + playbackID: playbackID + ) + ) { + HistoryFeature() + } + + await store.send(.playbackFinished(UUID())) + + #expect(store.state.playingTranscriptID == transcriptID) + #expect(store.state.playbackID == playbackID) + } +} diff --git a/HexTests/RecordingRaceTests.swift b/HexTests/RecordingRaceTests.swift index b21ec24..010681f 100644 --- a/HexTests/RecordingRaceTests.swift +++ b/HexTests/RecordingRaceTests.swift @@ -90,6 +90,146 @@ struct RecordingRaceTests { ) } + @Test + func shortRecordingReleasesSleepAssertion() async throws { + let now = Date(timeIntervalSince1970: 1_234) + let stopURL = FileManager.default.temporaryDirectory + .appendingPathComponent("short-recording-\(UUID().uuidString).wav") + let created = FileManager.default.createFile( + atPath: stopURL.path, + contents: Data("test".utf8) + ) + #expect(created) + defer { try? FileManager.default.removeItem(at: stopURL) } + + let probe = SleepProbe() + let store = TestStore(initialState: Self.makeState()) { + TranscriptionFeature() + } withDependencies: { + $0.date.now = now + $0.recording.startRecording = {} + $0.recording.stopRecording = { stopURL } + $0.sleepManagement.preventSleep = { _ in + await probe.recordPreventSleep() + } + $0.sleepManagement.allowSleep = { + await probe.recordAllowSleep() + } + $0.soundEffects.play = { _ in } + } + + await store.send(.startRecording) { + $0.isRecording = true + $0.recordingStartTime = now + $0.sourceAppBundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + $0.sourceAppName = NSWorkspace.shared.frontmostApplication?.localizedName + } + await store.send(.stopRecording) { + $0.isRecording = false + } + await store.finish() + + let counts = await probe.counts() + #expect(counts.preventSleepCalls == 1) + #expect(counts.allowSleepCalls == 1) + #expect(!FileManager.default.fileExists(atPath: stopURL.path)) + } + + @Test + func discardCancelsPendingRecordingStart() async { + let now = Date(timeIntervalSince1970: 1_234) + let stopURL = FileManager.default.temporaryDirectory + .appendingPathComponent("pending-start-discard-\(UUID().uuidString).wav") + let sleepProbe = PendingSleepProbe() + let recordingProbe = RecordingProbe(stopURL: stopURL) + let store = TestStore(initialState: Self.makeState()) { + TranscriptionFeature() + } withDependencies: { + $0.date.now = now + $0.recording.startRecording = { + await recordingProbe.recordStart() + } + $0.recording.stopRecording = { + await recordingProbe.beginImmediateStop() + } + $0.sleepManagement.preventSleep = { _ in + await sleepProbe.preventSleep() + } + $0.sleepManagement.allowSleep = {} + $0.soundEffects.play = { _ in } + } + + await store.send(.startRecording) { + $0.isRecording = true + $0.recordingStartTime = now + $0.sourceAppBundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + $0.sourceAppName = NSWorkspace.shared.frontmostApplication?.localizedName + } + await sleepProbe.waitUntilPending() + await store.send(.discard) { + $0.isRecording = false + $0.isPrewarming = false + } + await sleepProbe.resume() + await store.finish() + + let counts = await recordingProbe.counts() + #expect(counts.startCalls == 0) + #expect(counts.stopCalls == 1) + } + + @Test + func emptyTranscriptionDeletesCapturedAudio() async throws { + let audioURL = FileManager.default.temporaryDirectory + .appendingPathComponent("empty-transcription-\(UUID().uuidString).wav") + let created = FileManager.default.createFile( + atPath: audioURL.path, + contents: Data("test".utf8) + ) + #expect(created) + defer { try? FileManager.default.removeItem(at: audioURL) } + + let store = TestStore(initialState: Self.makeState()) { + TranscriptionFeature() + } + + await store.send(.transcriptionResult("", audioURL, 1.25)) + await store.finish() + + #expect(!FileManager.default.fileExists(atPath: audioURL.path)) + } + + @Test + func historyUsesRecordingDurationCapturedAtStop() async { + let duration = 1.25 + let audioURL = FileManager.default.temporaryDirectory + .appendingPathComponent("history-duration-\(UUID().uuidString).wav") + let probe = TranscriptPersistenceProbe() + let store = TestStore(initialState: Self.makeState()) { + TranscriptionFeature() + } withDependencies: { + $0.transcriptPersistence.save = { text, audioURL, duration, sourceAppBundleID, sourceAppName in + await probe.record(duration: duration) + return Transcript( + timestamp: Date(timeIntervalSince1970: 1_234), + text: text, + audioPath: audioURL, + duration: duration, + sourceAppBundleID: sourceAppBundleID, + sourceAppName: sourceAppName + ) + } + $0.pasteboard.paste = { _ in } + $0.soundEffects.play = { _ in } + } + + await store.send(.transcriptionResult("hello", audioURL, duration)) + await store.finish() + + let storedDuration = await probe.duration + #expect(storedDuration == duration) + } + private static func makeState() -> TranscriptionFeature.State { TranscriptionFeature.State( hexSettings: Shared(.init()), @@ -121,6 +261,11 @@ private actor RecordingProbe { } } + func beginImmediateStop() -> URL { + stopCalls += 1 + return stopURL + } + func waitForPendingStop() async { while stopContinuation == nil { await Task.yield() @@ -136,3 +281,49 @@ private actor RecordingProbe { (startCalls, stopCalls) } } + +private actor SleepProbe { + private var preventSleepCalls = 0 + private var allowSleepCalls = 0 + + func recordPreventSleep() { + preventSleepCalls += 1 + } + + func recordAllowSleep() { + allowSleepCalls += 1 + } + + func counts() -> (preventSleepCalls: Int, allowSleepCalls: Int) { + (preventSleepCalls, allowSleepCalls) + } +} + +private actor PendingSleepProbe { + private var continuation: CheckedContinuation? + + func preventSleep() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func waitUntilPending() async { + while continuation == nil { + await Task.yield() + } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + +private actor TranscriptPersistenceProbe { + private(set) var duration: TimeInterval? + + func record(duration: TimeInterval) { + self.duration = duration + } +}