From 02c1beff36b0af18a03c3155fefc57b742b89816 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Fri, 25 Sep 2026 20:32:38 +0000 Subject: [PATCH] fix: eliminate 5-10s freeze on silence, dismiss instantly when not speaking, and gate audio recovery to genuine voice energy --- src/AppDelegate.swift | 46 +++++++++++++++++++++++++++++++++++---- src/InputBoxManager.swift | 7 ++++++ src/SonioxClient.swift | 12 +++------- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 6dcd405..96c0cb0 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -167,12 +167,44 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } } + private func hasAudibleSpeech(pcmData: Data) -> Bool { + guard pcmData.count >= 3200 else { return false } + var sumSquares: Double = 0 + var peak: Int16 = 0 + let count = pcmData.count / 2 + pcmData.withUnsafeBytes { raw in + let ptr = raw.bindMemory(to: Int16.self) + for i in 0.. peak { peak = absSample } + let normalized = Double(sample) / 32768.0 + sumSquares += normalized * normalized + } + } + let rms = sqrt(sumSquares / Double(count)) + return peak > 1500 && rms > 0.010 + } + public func stopRecording() { guard audioRecorder.isRecording else { return } let pcmData = audioRecorder.stopRecording() self.lastRecordedAudio = pcmData + // Fast-path: If user did NOT speak (silence / short ambient hold), dismiss immediately with ZERO delay! + let hasSpeech = hasAudibleSpeech(pcmData: pcmData) + if !hasSpeech && !InputBoxManager.shared.hasStreamedText { + print("AppDelegate: No audible speech detected. Immediate dismissal without freeze.") + activeSession?.cancel() + activeSession = nil + isBusyFinalizing = false + statusBarController.updateIcon(state: .idle) + statusBarController.buildMenu(isRecording: false) + HUDOverlayController.shared.hide() + return + } + playSystemSound(name: "Pop") statusBarController.updateIcon(state: .transcribing) statusBarController.buildMenu(isRecording: false) @@ -180,8 +212,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { isBusyFinalizing = true - // Safety timeout watchdog: finalize must complete within 4.0s - DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in + // Safety timeout watchdog: finalize must complete within 2.5s + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in guard let self = self, self.isBusyFinalizing else { return } print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") self.activeSession?.completeWithCurrentText() @@ -195,18 +227,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { self.statusBarController.updateIcon(state: .idle) var rawText = "" + var isNetworkError = false switch result { case .success(let text): rawText = text.trimmingCharacters(in: .whitespacesAndNewlines) case .failure(let error): + isNetworkError = true print("Soniox primary finalize error:", error) } + let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) + // Check if smart audio recovery is needed: - let needsRetry = (rawText.isEmpty || self.isNetworkDegraded) && self.lastRecordedAudio.count > 25600 + // STRICT: Only retry if there was an ACTUAL network failure/drop AND audible speech was recorded. + // NEVER retry when Soniox cleanly returned empty text for silence! + let needsRetry = (isNetworkError || self.isNetworkDegraded) && hasSpeech && self.lastRecordedAudio.count > 16000 if needsRetry { - print("AppDelegate: Network drop or truncated output detected. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") + print("AppDelegate: Network drop detected with audible voice. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") HUDOverlayController.shared.show(state: .retrying) let audioCopy = self.lastRecordedAudio diff --git a/src/InputBoxManager.swift b/src/InputBoxManager.swift index 0df29df..b555c5d 100644 --- a/src/InputBoxManager.swift +++ b/src/InputBoxManager.swift @@ -17,6 +17,13 @@ public final class InputBoxManager { lock.unlock() } + /// Returns true if any text has been streamed into the input box during this session + public var hasStreamedText: Bool { + lock.lock() + defer { lock.unlock() } + return !accumulatedStreamedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + /// Prepares and guarantees that an input box is focused before recording starts. /// STRICT RULE: If an input box is already focused, NEVER click or steal focus! public func ensureFocusedInputBox() { diff --git a/src/SonioxClient.swift b/src/SonioxClient.swift index a0facd3..46cc6dc 100644 --- a/src/SonioxClient.swift +++ b/src/SonioxClient.swift @@ -294,13 +294,7 @@ public final class SonioxLiveSession { executeBurstSession(endpoint: primaryWsBase, pcmData: pcmData, languageHints: languageHints, onProgress: onProgress) { result in switch result { case .success(let text): - if !text.isEmpty { - completion(.success(text)) - } else { - // Try fallback endpoint - print("SonioxLiveSession: Primary retry returned empty, attempting fallback endpoint...") - executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, onProgress: onProgress, completion: completion) - } + completion(.success(text)) case .failure(let error): print("SonioxLiveSession: Primary retry failed (\(error)), attempting fallback endpoint...") executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, onProgress: onProgress, completion: completion) @@ -431,8 +425,8 @@ public final class SonioxLiveSession { task.send(finalizeMsg) { _ in } } - // Watchdog timeout for burst retry: max 5 seconds - DispatchQueue.global().asyncAfter(deadline: .now() + 5.0) { + // Watchdog timeout for burst retry: max 2.5 seconds (never hangs UI) + DispatchQueue.global().asyncAfter(deadline: .now() + 2.5) { let combined = fullCommitted.joined() + fullNonFinal.joined() finishOnce(with: combined) }