Add retry for failed transcriptions

This commit is contained in:
Beingpax 2025-06-16 13:54:26 +05:45
parent 63ea51113f
commit fde8b168eb
3 changed files with 128 additions and 40 deletions

View File

@ -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() {

View File

@ -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() {

View File

@ -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<Transcription>(
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() {