From fde8b168eb75833af0c43cf2d5bda3b0d218f70b Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 16 Jun 2025 13:54:26 +0545 Subject: [PATCH] Add retry for failed transcriptions --- .../Notifications/AppNotificationView.swift | 14 ++- .../Notifications/NotificationManager.swift | 36 +++--- VoiceInk/Whisper/WhisperState.swift | 118 +++++++++++++++--- 3 files changed, 128 insertions(+), 40 deletions(-) diff --git a/VoiceInk/Notifications/AppNotificationView.swift b/VoiceInk/Notifications/AppNotificationView.swift index 1e37846..2459973 100644 --- a/VoiceInk/Notifications/AppNotificationView.swift +++ b/VoiceInk/Notifications/AppNotificationView.swift @@ -6,6 +6,7 @@ struct AppNotificationView: View { let type: NotificationType let duration: TimeInterval let onClose: () -> Void + let onTap: (() -> Void)? @State private var progress: Double = 1.0 @State private var timer: Timer? @@ -37,9 +38,7 @@ struct AppNotificationView: View { var body: some View { ZStack { - // Main content HStack(alignment: .center, spacing: 12) { - // App icon on the left side if let appIcon = NSApp.applicationIconImage { Image(nsImage: appIcon) .resizable() @@ -64,9 +63,7 @@ struct AppNotificationView: View { Spacer() } .padding(12) - .frame(height: 60) // Fixed compact height - - // Close button overlaid on top-right + .frame(height: 60) VStack { HStack { Spacer() @@ -88,7 +85,6 @@ struct AppNotificationView: View { .fill(Color(nsColor: .controlBackgroundColor).opacity(0.95)) ) .overlay( - // Progress bar at the bottom VStack { Spacer() GeometryReader { geometry in @@ -107,6 +103,12 @@ struct AppNotificationView: View { .onDisappear { timer?.invalidate() } + .onTapGesture { + if let onTap = onTap { + onTap() + onClose() + } + } } private func startProgressTimer() { diff --git a/VoiceInk/Notifications/NotificationManager.swift b/VoiceInk/Notifications/NotificationManager.swift index afe5baf..fa26562 100644 --- a/VoiceInk/Notifications/NotificationManager.swift +++ b/VoiceInk/Notifications/NotificationManager.swift @@ -14,13 +14,16 @@ class NotificationManager { title: String, message: String, type: AppNotificationView.NotificationType, - duration: TimeInterval = 8.0 + duration: TimeInterval = 8.0, + onTap: (() -> Void)? = nil ) { - // If a notification is already showing, dismiss it before showing the new one. - if notificationWindow != nil { - dismissNotification() - } + dismissTimer?.invalidate() + dismissTimer = nil + if let existingWindow = notificationWindow { + existingWindow.close() + notificationWindow = nil + } let notificationView = AppNotificationView( title: title, message: message, @@ -30,7 +33,8 @@ class NotificationManager { Task { @MainActor in self?.dismissNotification() } - } + }, + onTap: onTap ) let hostingController = NSHostingController(rootView: notificationView) let size = hostingController.view.fittingSize @@ -44,19 +48,17 @@ class NotificationManager { panel.contentView = hostingController.view panel.isFloatingPanel = true - panel.level = .mainMenu - panel.backgroundColor = .clear + panel.level = NSWindow.Level.mainMenu + panel.backgroundColor = NSColor.clear panel.hasShadow = false panel.isMovableByWindowBackground = false - // Position at final location and start with fade animation positionWindow(panel) panel.alphaValue = 0 - panel.makeKeyAndOrderFront(nil) + panel.makeKeyAndOrderFront(nil as Any?) self.notificationWindow = panel - // Simple fade-in animation NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 context.timingFunction = CAMediaTimingFunction(name: .easeOut) @@ -74,19 +76,15 @@ class NotificationManager { @MainActor private func positionWindow(_ window: NSWindow) { - guard let screen = NSScreen.main else { return } - let screenRect = screen.visibleFrame + let activeScreen = NSApp.keyWindow?.screen ?? NSScreen.main ?? NSScreen.screens[0] + let screenRect = activeScreen.visibleFrame let windowRect = window.frame - let x = screenRect.maxX - windowRect.width - 20 // 20px padding from the right - let y = screenRect.maxY - windowRect.height - 20 // 20px padding from the top + let x = screenRect.maxX - windowRect.width - 20 + let y = screenRect.maxY - windowRect.height - 20 window.setFrameOrigin(NSPoint(x: x, y: y)) } - - - - @MainActor func dismissNotification() { diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 0ea2646..9e4e502 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -34,6 +34,8 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { @Published var isVisualizerActive = false + + @Published var isMiniRecorderVisible = false { didSet { if isMiniRecorderVisible { @@ -281,26 +283,24 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - guard let model = currentTranscriptionModel else { - logger.error("❌ Cannot transcribe: No model selected") - return - } + logger.notice("🔄 Starting transcription...") - logger.notice("🔄 Starting transcription with model: \(model.displayName)") + var permanentURL: URL? do { - // --- Core Transcription Logic --- + permanentURL = try saveRecordingPermanently(url) + + guard let model = currentTranscriptionModel else { + throw WhisperStateError.transcriptionFailed + } + + let transcriptionService: TranscriptionService = (model.provider == .local) ? localTranscriptionService : cloudTranscriptionService var text = try await transcriptionService.transcribe(audioURL: url, model: model) text = text.trimmingCharacters(in: .whitespacesAndNewlines) - logger.notice("✅ Transcription completed successfully, length: \(text.count) characters") - - // --- Post-processing and Saving --- - let permanentURL = try saveRecordingPermanently(url) if UserDefaults.standard.bool(forKey: "IsWordReplacementEnabled") { text = WordReplacementService.shared.applyReplacements(to: text) - logger.notice("✅ Word replacements applied") } let audioAsset = AVURLAsset(url: url) @@ -325,7 +325,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { text: originalText, duration: actualDuration, enhancedText: enhancedText, - audioFileURL: permanentURL.absoluteString + audioFileURL: permanentURL?.absoluteString ) modelContext.insert(newTranscription) try? modelContext.save() @@ -334,7 +334,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let newTranscription = Transcription( text: originalText, duration: actualDuration, - audioFileURL: permanentURL.absoluteString + audioFileURL: permanentURL?.absoluteString ) modelContext.insert(newTranscription) try? modelContext.save() @@ -343,7 +343,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let newTranscription = Transcription( text: originalText, duration: actualDuration, - audioFileURL: permanentURL.absoluteString + audioFileURL: permanentURL?.absoluteString ) modelContext.insert(newTranscription) try? modelContext.save() @@ -376,7 +376,48 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { await cleanupModelResources() } catch { - logger.error("❌ Transcription failed: \(error.localizedDescription)") + if let permanentURL = permanentURL { + do { + let audioAsset = AVURLAsset(url: permanentURL) + let duration = CMTimeGetSeconds(try await audioAsset.load(.duration)) + + await MainActor.run { + let failedTranscription = Transcription( + text: "Transcription Failed: \(error.localizedDescription)", + duration: duration, + enhancedText: nil, + audioFileURL: permanentURL.absoluteString + ) + + modelContext.insert(failedTranscription) + try? modelContext.save() + } + } catch { + // Silently continue if failed transcription record can't be saved + } + } + + await MainActor.run { + if permanentURL != nil { + NotificationManager.shared.showNotification( + title: "Transcription Failed", + message: "🔄 Tap to retry transcription", + type: .error, + onTap: { [weak self] in + Task { + await self?.retryLastTranscription() + } + } + ) + } else { + NotificationManager.shared.showNotification( + title: "Recording Failed", + message: "Could not save audio file. Please try recording again.", + type: .error + ) + } + } + await cleanupModelResources() await dismissMiniRecorder() } @@ -388,6 +429,53 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { try FileManager.default.copyItem(at: tempURL, to: permanentURL) return permanentURL } + + func retryLastTranscription() async { + do { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + let transcriptions = try modelContext.fetch(descriptor) + + guard let lastTranscription = transcriptions.first, + lastTranscription.text.hasPrefix("Transcription Failed"), + let audioURLString = lastTranscription.audioFileURL, + let audioURL = URL(string: audioURLString) else { + return + } + + guard let model = currentTranscriptionModel else { + throw WhisperStateError.transcriptionFailed + } + + let transcriptionService = AudioTranscriptionService(modelContext: modelContext, whisperState: self) + let newTranscription = try await transcriptionService.retranscribeAudio(from: audioURL, using: model) + + await MainActor.run { + NotificationManager.shared.showNotification( + title: "Transcription Successful", + message: "✅ Retry completed successfully", + type: .success + ) + + let textToPaste = newTranscription.enhancedText ?? newTranscription.text + if AXIsProcessTrusted() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + CursorPaster.pasteAtCursor(textToPaste + " ", shouldPreserveClipboard: !self.isAutoCopyEnabled) + } + } + } + + } catch { + await MainActor.run { + NotificationManager.shared.showNotification( + title: "Retry Failed", + message: "Transcription failed again. Check your model and settings.", + type: .error + ) + } + } + } // Loads the default transcription model from UserDefaults private func loadCurrentTranscriptionModel() {