// // TranscriptionFeature.swift // Hex // // Created by Kit Langton on 1/24/25. // import ComposableArchitecture import CoreGraphics import Foundation import HexCore import Inject import SwiftUI import WhisperKit private let transcriptionFeatureLogger = HexLog.transcription @Reducer struct TranscriptionFeature { @ObservableState struct State: Equatable { var isRecording: Bool = false var isTranscribing: Bool = false var isPrewarming: Bool = false var error: String? var recordingStartTime: Date? var meter: Meter = .init(averagePower: 0, peakPower: 0) var sourceAppBundleID: String? var sourceAppName: String? @Shared(.hexSettings) var hexSettings: HexSettings @Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false @Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory } enum Action { case task case audioLevelUpdated(Meter) // Hotkey actions case hotKeyPressed case hotKeyReleased // Recording flow case startRecording case stopRecording // Cancel/discard flow case cancel // Explicit cancellation with sound case discard // Silent discard (too short/accidental) // Transcription result flow case transcriptionResult(String, URL, TimeInterval) case transcriptionError(Error, URL?) // Model availability case modelMissing } enum CancelID { case metering case recordingStart case recordingCleanup case transcription } @Dependency(\.transcription) var transcription @Dependency(\.recording) var recording @Dependency(\.pasteboard) var pasteboard @Dependency(\.keyEventMonitor) var keyEventMonitor @Dependency(\.soundEffects) var soundEffect @Dependency(\.sleepManagement) var sleepManagement @Dependency(\.date.now) var now @Dependency(\.transcriptPersistence) var transcriptPersistence var body: some ReducerOf { Reduce { state, action in switch action { // MARK: - Lifecycle / Setup case .task: // Starts two concurrent effects: // 1) Observing audio meter // 2) Monitoring hot key events // 3) Priming the recorder for instant startup return .merge( startMeteringEffect(), startHotKeyMonitoringEffect(), warmUpRecorderEffect() ) // MARK: - Metering case let .audioLevelUpdated(meter): state.meter = meter return .none // MARK: - HotKey Flow case .hotKeyPressed: // If we're transcribing, send a cancel first. Otherwise start recording immediately. // We'll decide later (on release) whether to keep or discard the recording. return handleHotKeyPressed(isTranscribing: state.isTranscribing) case .hotKeyReleased: // If we're currently recording, then stop. Otherwise, just cancel // the delayed "startRecording" effect if we never actually started. return handleHotKeyReleased(isRecording: state.isRecording) // MARK: - Recording Flow case .startRecording: return handleStartRecording(&state) case .stopRecording: return handleStopRecording(&state) // MARK: - Transcription Results 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) case .modelMissing: return .none // MARK: - Cancel/Discard Flow case .cancel: // Only cancel if we're in the middle of recording, transcribing, or post-processing guard state.isRecording || state.isTranscribing else { return .none } return handleCancel(&state) case .discard: // Silent discard for quick/accidental recordings guard state.isRecording else { return .none } return handleDiscard(&state) } } } } // MARK: - Effects: Metering & HotKey private extension TranscriptionFeature { /// Effect to begin observing the audio meter. func startMeteringEffect() -> Effect { .run { send in for await meter in await recording.observeAudioLevel() { await send(.audioLevelUpdated(meter)) } } .cancellable(id: CancelID.metering, cancelInFlight: true) } /// Effect to start monitoring hotkey events through the `keyEventMonitor`. func startHotKeyMonitoringEffect() -> Effect { .run { send in var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option])) @Shared(.isSettingHotKey) var isSettingHotKey: Bool @Shared(.hexSettings) var hexSettings: HexSettings // Handle incoming input events (keyboard and mouse) let token = keyEventMonitor.handleInputEvent { inputEvent in // Skip if the user is currently setting a hotkey if isSettingHotKey { return false } // Always keep hotKeyProcessor in sync with current user hotkey preference hotKeyProcessor.hotkey = hexSettings.hotkey let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime switch inputEvent { case .keyboard(let keyEvent): // If Escape is pressed with no modifiers while idle, let's treat that as `cancel`. if keyEvent.key == .escape, keyEvent.modifiers.isEmpty, hotKeyProcessor.state == .idle { Task { await send(.cancel) } return false } // Process the key event switch hotKeyProcessor.process(keyEvent: keyEvent) { case .startRecording: Task { await send(.hotKeyPressed) } // If the hotkey is purely modifiers, return false to keep it from interfering with normal usage // But if useDoubleTapOnly is true, always intercept the key return useDoubleTapOnly || keyEvent.key != nil case .stopRecording: Task { await send(.hotKeyReleased) } return false // or `true` if you want to intercept case .cancel: Task { await send(.cancel) } return true case .discard: Task { await send(.discard) } return false // Don't intercept - let the key chord reach other apps case .none: // If we detect repeated same chord, maybe intercept. if let pressedKey = keyEvent.key, pressedKey == hotKeyProcessor.hotkey.key, keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers { return true } return false } case .mouseClick: // Process mouse click - for modifier-only hotkeys, this may cancel/discard switch hotKeyProcessor.processMouseClick() { case .cancel: Task { await send(.cancel) } return false // Don't intercept the click itself case .discard: Task { await send(.discard) } return false // Don't intercept the click itself case .startRecording, .stopRecording, .none: return false } } } defer { token.cancel() } await withTaskCancellationHandler { while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) } } onCancel: { token.cancel() } } } func warmUpRecorderEffect() -> Effect { .run { _ in await recording.warmUpRecorder() } } } // MARK: - HotKey Press/Release Handlers private extension TranscriptionFeature { func handleHotKeyPressed(isTranscribing: Bool) -> Effect { // If already transcribing, cancel first. Otherwise start recording immediately. guard isTranscribing else { return .send(.startRecording) } return .concatenate( .send(.cancel), .send(.startRecording) ) } func handleHotKeyReleased(isRecording: Bool) -> Effect { // Always stop recording when hotkey is released return isRecording ? .send(.stopRecording) : .none } } // MARK: - Recording Handlers private extension TranscriptionFeature { func handleStartRecording(_ state: inout State) -> Effect { guard state.modelBootstrapState.isModelReady else { return .merge( .send(.modelMissing), .run { _ in soundEffect.play(.cancel) } ) } state.isRecording = true let startTime = now state.recordingStartTime = startTime // Capture the active application if let activeApp = NSWorkspace.shared.frontmostApplication { state.sourceAppBundleID = activeApp.bundleIdentifier state.sourceAppName = activeApp.localizedName } transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())") // Prevent system sleep during recording return .merge( .cancel(id: CancelID.recordingCleanup), .run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in // Play sound immediately for instant feedback soundEffect.play(.startRecording) 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) ) } func handleStopRecording(_ state: inout State) -> Effect { state.isRecording = false let stopTime = now let startTime = state.recordingStartTime let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0 let decision = RecordingDecisionEngine.decide( .init( hotkey: state.hexSettings.hotkey, minimumKeyTime: state.hexSettings.minimumKeyTime, recordingStartTime: state.recordingStartTime, currentTime: stopTime ) ) let startStamp = startTime?.ISO8601Format() ?? "nil" let stopStamp = stopTime.ISO8601Format() let minimumKeyTime = state.hexSettings.minimumKeyTime let hotkeyHasKey = state.hexSettings.hotkey.key != nil transcriptionFeatureLogger.notice( "Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)" ) guard decision == .proceedToTranscription else { // 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 handleDiscard(&state) } let model = state.hexSettings.selectedModel guard !model.isEmpty else { // Defense-in-depth: handleStartRecording already blocks recording when the // bootstrap state says no model is ready, but settings can change while a // recording is in flight (or the in-memory bootstrap default can race a // cold launch). Never hand an empty model name to the transcriber: it // silently produces nothing (or junk like "[BLANK_AUDIO]"). transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio") return .merge( handleDiscard(&state), .send(.modelMissing) ) } // Otherwise, proceed to transcription state.isTranscribing = true state.error = nil let language = state.hexSettings.outputLanguage state.isPrewarming = true return .merge( .cancel(id: CancelID.recordingStart), .run { [sleepManagement] send in // Allow system to sleep again await sleepManagement.allowSleep() var audioURL: URL? defer { if let audioURL { FileManager.default.removeItemIfExists(at: audioURL) } } do { let stopResult = await recording.stopRecording() let capturedURL: URL switch stopResult { case let .captured(url): capturedURL = url case .ignored(.staleSession): transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session") return case .ignored(.noActiveRecording): transcriptionFeatureLogger.error("Recording stopped without captured audio") await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil)) return case let .failed(error): transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)") await send(.transcriptionError(error, nil)) return } 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)") audioURL = nil await send(.transcriptionResult(result, capturedURL, duration)) } catch { transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)") await send(.transcriptionError(error, nil)) } } .cancellable(id: CancelID.transcription) ) } } // MARK: - Transcription Handlers private extension TranscriptionFeature { func handleTranscriptionResult( _ state: inout State, result: String, audioURL: URL, duration: TimeInterval ) -> Effect { state.isTranscribing = false state.isPrewarming = false // Check for force quit command (emergency escape hatch) if ForceQuitCommandDetector.matches(result) { transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.") return .run { _ in FileManager.default.removeItemIfExists(at: audioURL) await MainActor.run { NSApp.terminate(nil) } } } // If empty text, nothing else to do guard !result.isEmpty else { return .run { _ in FileManager.default.removeItemIfExists(at: audioURL) } } transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'") let remappings = state.hexSettings.wordRemappings let removalsEnabled = state.hexSettings.wordRemovalsEnabled let removals = state.hexSettings.wordRemovals let modifiedResult: String if state.isRemappingScratchpadFocused { modifiedResult = result transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications") } else { var output = result if removalsEnabled { let removedResult = WordRemovalApplier.apply(output, removals: removals) if removedResult != output { let enabledRemovalCount = removals.filter(\.isEnabled).count transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)") } output = removedResult } let remappedResult = WordRemappingApplier.apply(output, remappings: remappings) if remappedResult != output { transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)") } let formattedResult = TranscriptFormattingApplier.apply( remappedResult, lowercase: state.hexSettings.lowercaseTranscripts, removePunctuation: state.hexSettings.removePunctuation ) if formattedResult != remappedResult { transcriptionFeatureLogger.info("Applied paste formatting") } modifiedResult = formattedResult } guard !modifiedResult.isEmpty else { return .run { _ in FileManager.default.removeItemIfExists(at: audioURL) } } let sourceAppBundleID = state.sourceAppBundleID let sourceAppName = state.sourceAppName let transcriptionHistory = state.$transcriptionHistory return .run { send in do { try await finalizeRecordingAndStoreTranscript( result: modifiedResult, duration: duration, sourceAppBundleID: sourceAppBundleID, sourceAppName: sourceAppName, audioURL: audioURL, transcriptionHistory: transcriptionHistory ) } catch { await send(.transcriptionError(error, audioURL)) } } .cancellable(id: CancelID.transcription) } func handleTranscriptionError( _ state: inout State, error: Error, audioURL: URL? ) -> Effect { state.isTranscribing = false state.isPrewarming = false state.error = error.localizedDescription if let audioURL { FileManager.default.removeItemIfExists(at: audioURL) } return .none } /// Move file to permanent location, create a transcript record, paste text, and play sound. func finalizeRecordingAndStoreTranscript( result: String, duration: TimeInterval, sourceAppBundleID: String?, sourceAppName: String?, audioURL: URL, transcriptionHistory: Shared ) async throws { @Shared(.hexSettings) var hexSettings: HexSettings if hexSettings.saveTranscriptionHistory { let transcript = try await transcriptPersistence.save( result, audioURL, duration, sourceAppBundleID, sourceAppName ) transcriptionHistory.withLock { history in history.history.insert(transcript, at: 0) if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 { while history.history.count > maxEntries { if let removedTranscript = history.history.popLast() { Task { try? await transcriptPersistence.deleteAudio(removedTranscript) } } } } } } else { FileManager.default.removeItemIfExists(at: audioURL) } await pasteboard.paste(result) soundEffect.play(.pasteTranscript) } } // MARK: - Cancel/Discard Handlers 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 result = await recording.stopRecording() if case let .captured(url) = result { FileManager.default.removeItemIfExists(at: url) } guard !Task.isCancelled else { return } soundEffect.play(.cancel) } .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) ) } func handleDiscard(_ state: inout State) -> Effect { state.isRecording = false state.isPrewarming = false // Silently discard - no sound effect return .merge( .cancel(id: CancelID.recordingStart), .run { [sleepManagement] _ in // Allow system to sleep again await sleepManagement.allowSleep() let result = await recording.stopRecording() if case let .captured(url) = result { FileManager.default.removeItemIfExists(at: url) } guard !Task.isCancelled else { return } } .cancellable(id: CancelID.recordingCleanup, cancelInFlight: true) ) } } // MARK: - View struct TranscriptionView: View { @Bindable var store: StoreOf @ObserveInjection var inject var status: TranscriptionIndicatorView.Status { if store.isTranscribing { return .transcribing } else if store.isRecording { return .recording } else if store.isPrewarming { return .prewarming } else { return .hidden } } var body: some View { TranscriptionIndicatorView( status: status, meter: store.meter ) .task { await store.send(.task).finish() } .enableInjection() } } // MARK: - Force Quit Command private enum ForceQuitCommandDetector { static func matches(_ text: String) -> Bool { let normalized = normalize(text) return normalized == "force quit hex now" || normalized == "force quit hex" } private static func normalize(_ text: String) -> String { text .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } .joined(separator: " ") } }