diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 6bba85b..030e4d9 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -272,7 +272,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { print("Failed to start audio recording:", error) activeSession?.cancel() activeSession = nil - HUDOverlayController.shared.show(state: .error) + HUDOverlayController.shared.show(state: .error(message: "REC ERROR")) } } @@ -338,13 +338,13 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // Dynamic safety timeout watchdog: scale with audio length & network transit let durationSec = Double(pcmData.count) / 32000.0 - let finalizeTimeout = InputBoxManager.shared.hasStreamedText ? 0.8 : max(1.2, min(4.0, durationSec * 0.15 + 1.2)) + let finalizeTimeout = max(2.5, min(8.0, durationSec * 0.2 + 2.5)) let watchdog = DispatchWorkItem { [weak self] in guard let self = self, self.isBusyFinalizing else { return } print("AppDelegate: Finalize safety watchdog triggered (\(String(format: "%.1f", finalizeTimeout))s) — starting Smart Audio Recovery.") self.finalizeWatchdogWorkItem = nil - self.startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: "Finalize timeout watchdog (\(String(format: "%.1f", finalizeTimeout))s)") + self.startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: "TIMEOUT") } self.finalizeWatchdogWorkItem = watchdog DispatchQueue.main.asyncAfter(deadline: .now() + finalizeTimeout, execute: watchdog) @@ -369,27 +369,26 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { print("Soniox primary finalize error:", error) } - // Strict Silence Gating: If server cleanly finished with empty text and no network drop, - // it was just silence/key tap. Dismiss immediately with zero freeze! - if rawText.isEmpty && !isNetworkError && !self.isNetworkDegraded { - print("AppDelegate: Server cleanly returned empty text for silence. Immediate dismissal.") + let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) + + // 1. Primary Success Path: Non-empty transcript returned + if !rawText.isEmpty { self.isBusyFinalizing = false - HUDOverlayController.shared.hide() + deliverOutputText(rawText) return } - let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) - let isTruncated = hasSpeech && self.lastRecordedAudio.count > 64000 && rawText.count < 10 - let needsRetry = (isNetworkError || self.isNetworkDegraded || isTruncated) && hasSpeech && self.lastRecordedAudio.count > 24000 - - if needsRetry { - let reason = isNetworkError ? "WebSocket network error" : "Truncated transcript (\(rawText.count) chars for \(self.lastRecordedAudio.count) bytes)" + // 2. Resilient Recovery Path: If user actually spoke but server returned empty/error/dropped + if hasSpeech && self.lastRecordedAudio.count >= 8000 { + let reason = isNetworkError ? "NET ERROR" : "RETRYING..." + print("AppDelegate: Primary STT returned empty/error for audible speech. Triggering Smart Audio Recovery...") startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: reason) return } + // 3. Strict Silence / Key-tap Gating: Pure silence or tap self.isBusyFinalizing = false - deliverOutputText(rawText) + HUDOverlayController.shared.hide() } private func startSmartAudioRecovery(pcmData: Data, reason: String) { @@ -405,8 +404,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { let durationSec = Double(pcmData.count) / 32000.0 print("AppDelegate: Starting Smart Audio Recovery [\(reason)] with \(pcmData.count) bytes (\(String(format: "%.1f", durationSec))s)...") - // Show glowing amber recovery state on HUD - HUDOverlayController.shared.show(state: .retrying) + // Show glowing amber recovery state on HUD with English reason + let hudReason = reason.contains("TIMEOUT") ? "TIMEOUT" : "RETRYING..." + HUDOverlayController.shared.show(state: .retrying(reason: hudReason)) statusBarController.updateIcon(state: .transcribing) // Cancel active session to prevent resource leaks @@ -446,10 +446,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { outcome: "FAILED: \(error.localizedDescription)", recoveredText: "" ) - HUDOverlayController.shared.show(state: .error) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - HUDOverlayController.shared.hide() + let errMsg: String + let errStr = error.localizedDescription.lowercased() + if errStr.contains("timed out") || errStr.contains("timeout") { + errMsg = "TIMEOUT" + } else if errStr.contains("network") || errStr.contains("connection") || errStr.contains("offline") { + errMsg = "NET ERROR" + } else { + errMsg = "STT ERROR" } + HUDOverlayController.shared.show(state: .error(message: errMsg)) } } } diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 25e4633..e6648bd 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -4,10 +4,10 @@ import QuartzCore public enum HUDState { case hidden case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil) - case retrying + case retrying(reason: String = "RETRYING...") case transcribing case success - case error + case error(message: String = "ERROR") } public final class HUDOverlayController { @@ -86,26 +86,36 @@ public final class HUDOverlayController { } orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) - case .retrying: + case .retrying(let reason): if !self.isVisible || window.alphaValue < 0.95 { window.orderFrontRegardless() window.alphaValue = 1.0 self.isVisible = true } - orb.showRetryState() + orb.showRetryState(reason: reason) case .transcribing: + if !self.isVisible || window.alphaValue < 0.95 { + window.orderFrontRegardless() + window.alphaValue = 1.0 + self.isVisible = true + } orb.showTranscribingState() case .success: orb.showSuccessState() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { self.hide() } - case .error: - orb.showErrorState() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.70) { + case .error(let message): + if !self.isVisible || window.alphaValue < 0.95 { + window.orderFrontRegardless() + window.alphaValue = 1.0 + self.isVisible = true + } + orb.showErrorState(message: message) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.40) { self.hide() } } @@ -263,10 +273,14 @@ final class OutwardExpandingOrbView: NSView { CATransaction.commit() } - func showRetryState() { + func showRetryState(reason: String = "RETRYING...") { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.20) + pingTextLayer.string = reason + pingTextLayer.fontSize = reason.count > 8 ? 9.0 : 10.0 + pingTextLayer.foregroundColor = NSColor(red: 0.25, green: 0.15, blue: 0.02, alpha: 0.95).cgColor + coreSolidLayer.backgroundColor = NSColor(red: 1.0, green: 0.75, blue: 0.20, alpha: 1.0).cgColor let amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 1.0).cgColor let strokeWidth: CGFloat = 5.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) @@ -283,6 +297,10 @@ final class OutwardExpandingOrbView: NSView { func showTranscribingState() { CATransaction.begin() CATransaction.setDisableActions(true) + pingTextLayer.string = "WAIT..." + pingTextLayer.fontSize = 11.0 + pingTextLayer.foregroundColor = NSColor.white.cgColor + coreSolidLayer.backgroundColor = NSColor(red: 0.15, green: 0.20, blue: 0.30, alpha: 1.0).cgColor let strokeWidth: CGFloat = 3.5 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) @@ -300,6 +318,10 @@ final class OutwardExpandingOrbView: NSView { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) + pingTextLayer.string = "OK" + pingTextLayer.fontSize = 11.0 + pingTextLayer.foregroundColor = NSColor(red: 0.04, green: 0.20, blue: 0.08, alpha: 0.95).cgColor + coreSolidLayer.backgroundColor = NSColor(red: 0.22, green: 0.82, blue: 0.44, alpha: 1.0).cgColor let emerald = NSColor(red: 0.18, green: 0.85, blue: 0.38, alpha: 1.0).cgColor // Vibrant Emerald Green let strokeWidth: CGFloat = 6.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0) @@ -313,10 +335,14 @@ final class OutwardExpandingOrbView: NSView { CATransaction.commit() } - func showErrorState() { + func showErrorState(message: String = "ERROR") { CATransaction.begin() CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.18) + pingTextLayer.string = message + pingTextLayer.fontSize = message.count > 8 ? 9.0 : 10.0 + pingTextLayer.foregroundColor = NSColor.white.cgColor + coreSolidLayer.backgroundColor = NSColor(red: 0.94, green: 0.22, blue: 0.22, alpha: 1.0).cgColor let darkRed = NSColor(red: 0.95, green: 0.15, blue: 0.15, alpha: 1.0).cgColor let strokeWidth: CGFloat = 5.0 let strokeDiameter = baseDiameter + (strokeWidth * 2.0)