|
|
@ -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..<count { |
|
|
|
|
|
let sample = ptr[i] |
|
|
|
|
|
let absSample = sample == Int16.min ? Int16.max : abs(sample) |
|
|
|
|
|
if absSample > 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() { |
|
|
public func stopRecording() { |
|
|
guard audioRecorder.isRecording else { return } |
|
|
guard audioRecorder.isRecording else { return } |
|
|
|
|
|
|
|
|
let pcmData = audioRecorder.stopRecording() |
|
|
let pcmData = audioRecorder.stopRecording() |
|
|
self.lastRecordedAudio = pcmData |
|
|
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") |
|
|
playSystemSound(name: "Pop") |
|
|
statusBarController.updateIcon(state: .transcribing) |
|
|
statusBarController.updateIcon(state: .transcribing) |
|
|
statusBarController.buildMenu(isRecording: false) |
|
|
statusBarController.buildMenu(isRecording: false) |
|
|
@ -180,8 +212,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
|
isBusyFinalizing = true |
|
|
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 } |
|
|
guard let self = self, self.isBusyFinalizing else { return } |
|
|
print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") |
|
|
print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") |
|
|
self.activeSession?.completeWithCurrentText() |
|
|
self.activeSession?.completeWithCurrentText() |
|
|
@ -195,18 +227,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
|
|
|
|
|
|
var rawText = "" |
|
|
var rawText = "" |
|
|
|
|
|
var isNetworkError = false |
|
|
switch result { |
|
|
switch result { |
|
|
case .success(let text): |
|
|
case .success(let text): |
|
|
rawText = text.trimmingCharacters(in: .whitespacesAndNewlines) |
|
|
rawText = text.trimmingCharacters(in: .whitespacesAndNewlines) |
|
|
case .failure(let error): |
|
|
case .failure(let error): |
|
|
|
|
|
isNetworkError = true |
|
|
print("Soniox primary finalize error:", error) |
|
|
print("Soniox primary finalize error:", error) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) |
|
|
|
|
|
|
|
|
// Check if smart audio recovery is needed: |
|
|
// 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 { |
|
|
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) |
|
|
HUDOverlayController.shared.show(state: .retrying) |
|
|
|
|
|
|
|
|
let audioCopy = self.lastRecordedAudio |
|
|
let audioCopy = self.lastRecordedAudio |
|
|
|