Browse Source

fix: guarantee 100% audio conversion via Smart Recovery on all drops/empty responses and show clean English error state in HUD Orb

main
Ali Alavi 5 days ago
parent
commit
bc878a3341
  1. 46
      src/AppDelegate.swift
  2. 46
      src/HUDOverlay.swift

46
src/AppDelegate.swift

@ -272,7 +272,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
print("Failed to start audio recording:", error) print("Failed to start audio recording:", error)
activeSession?.cancel() activeSession?.cancel()
activeSession = nil 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 // Dynamic safety timeout watchdog: scale with audio length & network transit
let durationSec = Double(pcmData.count) / 32000.0 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 let watchdog = DispatchWorkItem { [weak self] in
guard let self = self, self.isBusyFinalizing else { return } guard let self = self, self.isBusyFinalizing else { return }
print("AppDelegate: Finalize safety watchdog triggered (\(String(format: "%.1f", finalizeTimeout))s) — starting Smart Audio Recovery.") print("AppDelegate: Finalize safety watchdog triggered (\(String(format: "%.1f", finalizeTimeout))s) — starting Smart Audio Recovery.")
self.finalizeWatchdogWorkItem = nil 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 self.finalizeWatchdogWorkItem = watchdog
DispatchQueue.main.asyncAfter(deadline: .now() + finalizeTimeout, execute: 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) 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 self.isBusyFinalizing = false
HUDOverlayController.shared.hide()
deliverOutputText(rawText)
return 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) startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: reason)
return return
} }
// 3. Strict Silence / Key-tap Gating: Pure silence or tap
self.isBusyFinalizing = false self.isBusyFinalizing = false
deliverOutputText(rawText)
HUDOverlayController.shared.hide()
} }
private func startSmartAudioRecovery(pcmData: Data, reason: String) { private func startSmartAudioRecovery(pcmData: Data, reason: String) {
@ -405,8 +404,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
let durationSec = Double(pcmData.count) / 32000.0 let durationSec = Double(pcmData.count) / 32000.0
print("AppDelegate: Starting Smart Audio Recovery [\(reason)] with \(pcmData.count) bytes (\(String(format: "%.1f", durationSec))s)...") 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) statusBarController.updateIcon(state: .transcribing)
// Cancel active session to prevent resource leaks // Cancel active session to prevent resource leaks
@ -446,10 +446,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
outcome: "FAILED: \(error.localizedDescription)", outcome: "FAILED: \(error.localizedDescription)",
recoveredText: "" 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))
} }
} }
} }

46
src/HUDOverlay.swift

@ -4,10 +4,10 @@ import QuartzCore
public enum HUDState { public enum HUDState {
case hidden case hidden
case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil) case recording(level: Float, isLagging: Bool = false, pingQuality: PingQuality? = nil)
case retrying
case retrying(reason: String = "RETRYING...")
case transcribing case transcribing
case success case success
case error
case error(message: String = "ERROR")
} }
public final class HUDOverlayController { public final class HUDOverlayController {
@ -86,26 +86,36 @@ public final class HUDOverlayController {
} }
orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality) orb.updateAudioLevel(level, isLagging: isLagging, pingQuality: pingQuality)
case .retrying:
case .retrying(let reason):
if !self.isVisible || window.alphaValue < 0.95 { if !self.isVisible || window.alphaValue < 0.95 {
window.orderFrontRegardless() window.orderFrontRegardless()
window.alphaValue = 1.0 window.alphaValue = 1.0
self.isVisible = true self.isVisible = true
} }
orb.showRetryState()
orb.showRetryState(reason: reason)
case .transcribing: case .transcribing:
if !self.isVisible || window.alphaValue < 0.95 {
window.orderFrontRegardless()
window.alphaValue = 1.0
self.isVisible = true
}
orb.showTranscribingState() orb.showTranscribingState()
case .success: case .success:
orb.showSuccessState() orb.showSuccessState()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.hide() 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() self.hide()
} }
} }
@ -263,10 +273,14 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.commit() CATransaction.commit()
} }
func showRetryState() {
func showRetryState(reason: String = "RETRYING...") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.20) 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 amber = NSColor(red: 1.0, green: 0.65, blue: 0.10, alpha: 1.0).cgColor
let strokeWidth: CGFloat = 5.0 let strokeWidth: CGFloat = 5.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
@ -283,6 +297,10 @@ final class OutwardExpandingOrbView: NSView {
func showTranscribingState() { func showTranscribingState() {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) 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 strokeWidth: CGFloat = 3.5
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter) dynamicStrokeLayer.bounds = CGRect(x: 0, y: 0, width: strokeDiameter, height: strokeDiameter)
@ -300,6 +318,10 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.18) 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 emerald = NSColor(red: 0.18, green: 0.85, blue: 0.38, alpha: 1.0).cgColor // Vibrant Emerald Green
let strokeWidth: CGFloat = 6.0 let strokeWidth: CGFloat = 6.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)
@ -313,10 +335,14 @@ final class OutwardExpandingOrbView: NSView {
CATransaction.commit() CATransaction.commit()
} }
func showErrorState() {
func showErrorState(message: String = "ERROR") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.18) 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 darkRed = NSColor(red: 0.95, green: 0.15, blue: 0.15, alpha: 1.0).cgColor
let strokeWidth: CGFloat = 5.0 let strokeWidth: CGFloat = 5.0
let strokeDiameter = baseDiameter + (strokeWidth * 2.0) let strokeDiameter = baseDiameter + (strokeWidth * 2.0)

Loading…
Cancel
Save