|
|
|
@ -3,20 +3,53 @@ import AVFoundation |
|
|
|
import ApplicationServices |
|
|
|
|
|
|
|
public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
public static private(set) var shared: AppDelegate? |
|
|
|
|
|
|
|
public override init() { |
|
|
|
super.init() |
|
|
|
AppDelegate.shared = self |
|
|
|
} |
|
|
|
private var statusBarController: StatusBarController! |
|
|
|
private var audioRecorder = AudioRecorder() |
|
|
|
private var activeSession: SonioxLiveSession? |
|
|
|
private var isBusyFinalizing = false |
|
|
|
private var currentAudioLevel: Float = 0.0 |
|
|
|
private var handsFreeSilenceTimer: Timer? |
|
|
|
private var lastVoiceDetectedTime: Date = Date() |
|
|
|
private var recordingStartTime: Date = Date() |
|
|
|
private var hasSpokenInCurrentSession: Bool = false |
|
|
|
private var pendingStopWorkItem: DispatchWorkItem? = nil |
|
|
|
private var isNetworkDegraded: Bool = false |
|
|
|
private var lastRecordedAudio: Data = Data() |
|
|
|
private var finalizeWatchdogWorkItem: DispatchWorkItem? = nil |
|
|
|
|
|
|
|
public func applicationDidFinishLaunching(_ notification: Notification) { |
|
|
|
setbuf(stdout, nil) |
|
|
|
setbuf(stderr, nil) |
|
|
|
if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { |
|
|
|
UserDefaults.standard.set(true, forKey: "SonioxPlaySounds") |
|
|
|
} |
|
|
|
|
|
|
|
// Setup DistributedNotificationCenter observers for Shortcuts AppIntents & external triggers |
|
|
|
DistributedNotificationCenter.default().addObserver( |
|
|
|
self, |
|
|
|
selector: #selector(handleDistributedStartRecording), |
|
|
|
name: NSNotification.Name("com.soniox.voice.start"), |
|
|
|
object: nil |
|
|
|
) |
|
|
|
DistributedNotificationCenter.default().addObserver( |
|
|
|
self, |
|
|
|
selector: #selector(handleDistributedStopRecording), |
|
|
|
name: NSNotification.Name("com.soniox.voice.stop"), |
|
|
|
object: nil |
|
|
|
) |
|
|
|
DistributedNotificationCenter.default().addObserver( |
|
|
|
self, |
|
|
|
selector: #selector(handleDistributedToggleRecording), |
|
|
|
name: NSNotification.Name("com.soniox.voice.toggle"), |
|
|
|
object: nil |
|
|
|
) |
|
|
|
|
|
|
|
statusBarController = StatusBarController() |
|
|
|
statusBarController.onToggleRecording = { [weak self] in |
|
|
|
self?.toggleRecording() |
|
|
|
@ -48,8 +81,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
} |
|
|
|
self.pendingStopWorkItem = workItem |
|
|
|
// Trailing grace period: 0.5s (500ms) buffer so trailing words/syllables are never clipped |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.50, execute: workItem) |
|
|
|
// Trailing grace period: 0.35s (350ms) buffer after key release so trailing fast speech is never clipped |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -64,6 +97,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
audioRecorder.onAudioLevelUpdate = { [weak self] level in |
|
|
|
guard let self = self else { return } |
|
|
|
self.currentAudioLevel = level |
|
|
|
if level > 0.07 { |
|
|
|
self.hasSpokenInCurrentSession = true |
|
|
|
self.lastVoiceDetectedTime = Date() |
|
|
|
} |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded, pingQuality: PingMonitor.shared.currentQuality)) |
|
|
|
} |
|
|
|
@ -91,6 +128,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
// Check permissions on start |
|
|
|
checkInitialPermissions() |
|
|
|
setupDarwinNotifications() |
|
|
|
} |
|
|
|
|
|
|
|
private func checkInitialPermissions() { |
|
|
|
@ -109,8 +147,19 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
|
|
|
|
private func playSystemSound(name: String) { |
|
|
|
if SonioxSettings.shared.playSounds { |
|
|
|
NSSound(named: NSSound.Name(name))?.play() |
|
|
|
guard SonioxSettings.shared.playSounds else { return } |
|
|
|
DispatchQueue.main.async { |
|
|
|
if let sound = NSSound(named: NSSound.Name(name)) { |
|
|
|
sound.volume = 1.0 |
|
|
|
sound.play() |
|
|
|
} else { |
|
|
|
DispatchQueue.global(qos: .userInitiated).async { |
|
|
|
let p = Process() |
|
|
|
p.executableURL = URL(fileURLWithPath: "/usr/bin/afplay") |
|
|
|
p.arguments = ["/System/Library/Sounds/\(name).aiff"] |
|
|
|
try? p.run() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -124,6 +173,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
public func startRecording() { |
|
|
|
if audioRecorder.isRecording { return } |
|
|
|
finalizeWatchdogWorkItem?.cancel() |
|
|
|
finalizeWatchdogWorkItem = nil |
|
|
|
if isBusyFinalizing { |
|
|
|
print("AppDelegate: isBusyFinalizing was active when startRecording invoked. Clearing stale lock.") |
|
|
|
isBusyFinalizing = false |
|
|
|
@ -131,12 +182,22 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
isNetworkDegraded = false |
|
|
|
lastRecordedAudio.removeAll() |
|
|
|
recordingStartTime = Date() |
|
|
|
lastVoiceDetectedTime = Date() |
|
|
|
hasSpokenInCurrentSession = false |
|
|
|
|
|
|
|
// Start Hands-free silence watchdog timer (checks every 250ms) |
|
|
|
handsFreeSilenceTimer?.invalidate() |
|
|
|
handsFreeSilenceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in |
|
|
|
self?.checkHandsFreeSilence() |
|
|
|
} |
|
|
|
|
|
|
|
// 1. Ensure target input box is focused without stealing user cursor |
|
|
|
InputBoxManager.shared.ensureFocusedInputBox() |
|
|
|
|
|
|
|
// 2. Acquire pre-warmed / fast-connected WebSocket session |
|
|
|
let session = SonioxSessionPool.shared.acquireSession() |
|
|
|
session.setRecordingActive(true) |
|
|
|
self.activeSession = session |
|
|
|
|
|
|
|
// Handle network degradation warning |
|
|
|
@ -149,7 +210,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
|
|
|
|
// Stream live finalized tokens directly into input box word-by-word |
|
|
|
session.onCommittedDelta = { delta in |
|
|
|
session.onCommittedDelta = { [weak self] delta in |
|
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
// Check for in-stream end trigger ("and", "تمام", "end") |
|
|
|
let deltaWords = delta.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } |
|
|
|
if let lastWord = deltaWords.last?.replacingOccurrences(of: #"[.,!?:;،؛؟"'()\[\]{}«»–—\-]"#, with: "", options: .regularExpression).lowercased() { |
|
|
|
if lastWord == "and" || lastWord == "تمام" || lastWord == "end" { |
|
|
|
print("AppDelegate: In-speech end trigger detected ('\(lastWord)'). Finalizing speech...") |
|
|
|
DispatchQueue.main.async { |
|
|
|
self.stopRecording() |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
self.hasSpokenInCurrentSession = true |
|
|
|
self.lastVoiceDetectedTime = Date() |
|
|
|
|
|
|
|
// If it's an executive slash command, do NOT type it into the input box |
|
|
|
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { |
|
|
|
return |
|
|
|
@ -157,9 +235,29 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
InputBoxManager.shared.streamDelta(text: delta) |
|
|
|
} |
|
|
|
|
|
|
|
session.onEndTriggerDetected = { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
print("AppDelegate: In-flight end trigger ('and'/'تمام') detected. Stopping recording immediately...") |
|
|
|
DispatchQueue.main.async { |
|
|
|
self.stopRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
session.onFinalResult = { [weak self] result in |
|
|
|
DispatchQueue.main.async { |
|
|
|
guard let self = self else { return } |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
if HotkeyManager.shared.isPhysicalKeyDown { |
|
|
|
print("AppDelegate: Session finished/failed while user is physically holding hotkey. Marking degraded; recovery will run upon release.") |
|
|
|
self.isNetworkDegraded = true |
|
|
|
HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: true)) |
|
|
|
return |
|
|
|
} else { |
|
|
|
print("AppDelegate: Session finished in hands-free/Siri mode. Stopping recording and finalizing.") |
|
|
|
self.stopRecording() |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
self.handleFinalResult(result) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -174,12 +272,13 @@ 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")) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private func hasAudibleSpeech(pcmData: Data) -> Bool { |
|
|
|
guard pcmData.count >= 3200 else { return false } |
|
|
|
// Less than 0.25s of audio is just a momentary key tap/click |
|
|
|
guard pcmData.count >= 8000 else { return false } |
|
|
|
var sumSquares: Double = 0 |
|
|
|
var peak: Int16 = 0 |
|
|
|
let count = pcmData.count / 2 |
|
|
|
@ -194,20 +293,24 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
} |
|
|
|
let rms = sqrt(sumSquares / Double(count)) |
|
|
|
return peak > 1500 && rms > 0.010 |
|
|
|
// Raised thresholds: filter out mechanical keyclicks and keyboard taps |
|
|
|
return peak > 2500 && rms > 0.015 |
|
|
|
} |
|
|
|
|
|
|
|
public func stopRecording() { |
|
|
|
handsFreeSilenceTimer?.invalidate() |
|
|
|
handsFreeSilenceTimer = nil |
|
|
|
HotkeyManager.shared.resetKeyState() |
|
|
|
guard audioRecorder.isRecording else { return } |
|
|
|
|
|
|
|
activeSession?.setRecordingActive(false) |
|
|
|
let pcmData = audioRecorder.stopRecording() |
|
|
|
self.lastRecordedAudio = pcmData |
|
|
|
|
|
|
|
// Fast-path: If user did NOT speak (silence / short ambient hold), dismiss immediately with ZERO delay! |
|
|
|
// Fast-path: If user tapped key or did NOT speak (silence / keyclick), 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.") |
|
|
|
if (!hasSpeech && !InputBoxManager.shared.hasStreamedText) || pcmData.count < 8000 { |
|
|
|
print("AppDelegate: Short tap or silence detected. Immediate 0ms dismissal.") |
|
|
|
activeSession?.cancel() |
|
|
|
activeSession = nil |
|
|
|
isBusyFinalizing = false |
|
|
|
@ -220,24 +323,39 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
playSystemSound(name: "Pop") |
|
|
|
statusBarController.updateIcon(state: .transcribing) |
|
|
|
statusBarController.buildMenu(isRecording: false) |
|
|
|
HUDOverlayController.shared.show(state: .transcribing) |
|
|
|
|
|
|
|
isBusyFinalizing = true |
|
|
|
|
|
|
|
// Safety timeout watchdog: finalize must complete within 2.5s |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in |
|
|
|
// Check if the live session suffered a network failure during recording |
|
|
|
let sessionDropped = (self.activeSession == nil) || (self.activeSession?.hasNetworkFailure == true) || self.isNetworkDegraded |
|
|
|
if sessionDropped { |
|
|
|
print("AppDelegate: Live session dropped/degraded during recording. Triggering Smart Audio Recovery immediately.") |
|
|
|
startSmartAudioRecovery(pcmData: pcmData, reason: "Live session dropped mid-recording") |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
HUDOverlayController.shared.show(state: .transcribing) |
|
|
|
|
|
|
|
// Dynamic safety timeout watchdog: scale with audio length & network transit |
|
|
|
let durationSec = Double(pcmData.count) / 32000.0 |
|
|
|
let finalizeTimeout = max(4.0, min(12.0, durationSec * 0.25 + 3.5)) |
|
|
|
|
|
|
|
let watchdog = DispatchWorkItem { [weak self] in |
|
|
|
guard let self = self, self.isBusyFinalizing else { return } |
|
|
|
print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") |
|
|
|
self.isBusyFinalizing = false |
|
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
|
HUDOverlayController.shared.hide() |
|
|
|
self.activeSession?.completeWithCurrentText() |
|
|
|
print("AppDelegate: Finalize safety watchdog triggered (\(String(format: "%.1f", finalizeTimeout))s) — starting Smart Audio Recovery.") |
|
|
|
self.finalizeWatchdogWorkItem = nil |
|
|
|
self.startSmartAudioRecovery(pcmData: self.lastRecordedAudio, reason: "TIMEOUT") |
|
|
|
} |
|
|
|
self.finalizeWatchdogWorkItem = watchdog |
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + finalizeTimeout, execute: watchdog) |
|
|
|
|
|
|
|
activeSession?.finalizeStream() |
|
|
|
} |
|
|
|
|
|
|
|
private func handleFinalResult(_ result: Result<String, Error>) { |
|
|
|
finalizeWatchdogWorkItem?.cancel() |
|
|
|
finalizeWatchdogWorkItem = nil |
|
|
|
guard isBusyFinalizing else { return } |
|
|
|
self.activeSession = nil |
|
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
|
|
|
|
|
@ -253,37 +371,93 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
|
|
|
|
let hasSpeech = hasAudibleSpeech(pcmData: self.lastRecordedAudio) |
|
|
|
|
|
|
|
// Check if smart audio recovery is needed: |
|
|
|
// 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 |
|
|
|
// 1. Primary Success Path: Non-empty transcript returned |
|
|
|
if !rawText.isEmpty { |
|
|
|
self.isBusyFinalizing = false |
|
|
|
deliverOutputText(rawText) |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
if needsRetry { |
|
|
|
print("AppDelegate: Network drop detected with audible voice. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") |
|
|
|
HUDOverlayController.shared.show(state: .retrying) |
|
|
|
// 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 |
|
|
|
} |
|
|
|
|
|
|
|
let audioCopy = self.lastRecordedAudio |
|
|
|
let hints = SonioxSettings.shared.language.hints |
|
|
|
let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil |
|
|
|
// 3. Strict Silence / Key-tap Gating: Pure silence or tap |
|
|
|
self.isBusyFinalizing = false |
|
|
|
HUDOverlayController.shared.hide() |
|
|
|
} |
|
|
|
|
|
|
|
SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, targetLanguage: target, onProgress: nil) { [weak self] retryResult in |
|
|
|
guard let self = self else { return } |
|
|
|
self.isBusyFinalizing = false |
|
|
|
|
|
|
|
switch retryResult { |
|
|
|
case .success(let recoveredText): |
|
|
|
let trimmed = recoveredText.trimmingCharacters(in: .whitespacesAndNewlines) |
|
|
|
let finalText = trimmed.isEmpty ? rawText : trimmed |
|
|
|
self.deliverOutputText(finalText) |
|
|
|
case .failure: |
|
|
|
self.deliverOutputText(rawText) |
|
|
|
} |
|
|
|
} |
|
|
|
private func startSmartAudioRecovery(pcmData: Data, reason: String) { |
|
|
|
finalizeWatchdogWorkItem?.cancel() |
|
|
|
finalizeWatchdogWorkItem = nil |
|
|
|
guard !pcmData.isEmpty else { |
|
|
|
self.isBusyFinalizing = false |
|
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
|
HUDOverlayController.shared.hide() |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
self.isBusyFinalizing = false |
|
|
|
deliverOutputText(rawText) |
|
|
|
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 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 |
|
|
|
self.activeSession?.cancel() |
|
|
|
self.activeSession = nil |
|
|
|
|
|
|
|
// Concurrently prime a fresh session pool in background for the NEXT speech |
|
|
|
SonioxSessionPool.shared.reconnectAll() |
|
|
|
|
|
|
|
let hints = SonioxSettings.shared.language.hints |
|
|
|
let target = SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil |
|
|
|
|
|
|
|
SonioxLiveSession.retryFullBufferedAudio(pcmData: pcmData, languageHints: hints, targetLanguage: target, onProgress: nil) { [weak self] retryResult in |
|
|
|
guard let self = self else { return } |
|
|
|
self.isBusyFinalizing = false |
|
|
|
self.statusBarController.updateIcon(state: .idle) |
|
|
|
|
|
|
|
switch retryResult { |
|
|
|
case .success(let recoveredText): |
|
|
|
let trimmed = recoveredText.trimmingCharacters(in: .whitespacesAndNewlines) |
|
|
|
print("AppDelegate: Smart Audio Recovery SUCCEEDED! Recovered \(trimmed.count) chars.") |
|
|
|
VoiceCommandLogger.shared.logRecovery( |
|
|
|
audioBytes: pcmData.count, |
|
|
|
durationSec: durationSec, |
|
|
|
reason: reason, |
|
|
|
outcome: "SUCCESS", |
|
|
|
recoveredText: trimmed |
|
|
|
) |
|
|
|
self.deliverOutputText(trimmed) |
|
|
|
|
|
|
|
case .failure(let error): |
|
|
|
print("AppDelegate: Smart Audio Recovery failed completely: \(error)") |
|
|
|
VoiceCommandLogger.shared.logRecovery( |
|
|
|
audioBytes: pcmData.count, |
|
|
|
durationSec: durationSec, |
|
|
|
reason: reason, |
|
|
|
outcome: "FAILED: \(error.localizedDescription)", |
|
|
|
recoveredText: "" |
|
|
|
) |
|
|
|
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)) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private func deliverOutputText(_ text: String) { |
|
|
|
@ -311,7 +485,154 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
public func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { |
|
|
|
print("AppDelegate: Application reopen triggered (Siri / Shortcut).") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
if self.audioRecorder.isRecording { |
|
|
|
print("AppDelegate: Already recording on reopen — stopping and finalizing.") |
|
|
|
self.stopRecording() |
|
|
|
} else { |
|
|
|
self.startRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
public func application(_ application: NSApplication, open urls: [URL]) { |
|
|
|
for url in urls { |
|
|
|
if url.scheme == "soniox" { |
|
|
|
let action = url.host ?? url.path |
|
|
|
if action.contains("start") { |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.startRecording() |
|
|
|
} |
|
|
|
} else if action.contains("stop") { |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.stopRecording() |
|
|
|
} |
|
|
|
} else if action.contains("toggle") { |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.toggleRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
public func applicationWillTerminate(_ notification: Notification) { |
|
|
|
HotkeyManager.shared.unregisterHotkeys() |
|
|
|
} |
|
|
|
|
|
|
|
private func setupDarwinNotifications() { |
|
|
|
let notifyCenter = CFNotificationCenterGetDarwinNotifyCenter() |
|
|
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
|
|
|
|
|
|
|
let startCb: CFNotificationCallback = { (_, observer, _, _, _) in |
|
|
|
guard let observer = observer else { return } |
|
|
|
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue() |
|
|
|
DispatchQueue.main.async { |
|
|
|
if !app.audioRecorder.isRecording { |
|
|
|
print("AppDelegate: Received Darwin notification 'com.soniox.voice.start'. Starting recording...") |
|
|
|
app.startRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let stopCb: CFNotificationCallback = { (_, observer, _, _, _) in |
|
|
|
guard let observer = observer else { return } |
|
|
|
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue() |
|
|
|
DispatchQueue.main.async { |
|
|
|
if app.audioRecorder.isRecording { |
|
|
|
print("AppDelegate: Received Darwin notification 'com.soniox.voice.stop'. Stopping recording...") |
|
|
|
app.stopRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let toggleCb: CFNotificationCallback = { (_, observer, _, _, _) in |
|
|
|
guard let observer = observer else { return } |
|
|
|
let app = Unmanaged<AppDelegate>.fromOpaque(observer).takeUnretainedValue() |
|
|
|
DispatchQueue.main.async { |
|
|
|
print("AppDelegate: Received Darwin notification 'com.soniox.voice.toggle'.") |
|
|
|
app.toggleRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
CFNotificationCenterAddObserver(notifyCenter, selfPtr, startCb, "com.soniox.voice.start" as CFString, nil, .deliverImmediately) |
|
|
|
CFNotificationCenterAddObserver(notifyCenter, selfPtr, stopCb, "com.soniox.voice.stop" as CFString, nil, .deliverImmediately) |
|
|
|
CFNotificationCenterAddObserver(notifyCenter, selfPtr, toggleCb, "com.soniox.voice.toggle" as CFString, nil, .deliverImmediately) |
|
|
|
} |
|
|
|
|
|
|
|
private func checkHandsFreeSilence() { |
|
|
|
guard audioRecorder.isRecording else { |
|
|
|
handsFreeSilenceTimer?.invalidate() |
|
|
|
handsFreeSilenceTimer = nil |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
// If user is physically holding down a hotkey, do NOT auto-stop |
|
|
|
if HotkeyManager.shared.isPhysicalKeyDown { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
let now = Date() |
|
|
|
let elapsedSinceStart = now.timeIntervalSince(recordingStartTime) |
|
|
|
|
|
|
|
// 1. Initial silence timeout: if no speech detected for 5.0s after start, auto-dismiss |
|
|
|
if !hasSpokenInCurrentSession && !InputBoxManager.shared.hasStreamedText { |
|
|
|
if elapsedSinceStart >= 5.0 { |
|
|
|
print("AppDelegate: Hands-free initial silence timeout (5.0s). Auto-dismissing.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.stopRecording() |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 2. Trailing silence timeout: if speech was detected and now silent for 2.0s, auto-finalize! |
|
|
|
if hasSpokenInCurrentSession || InputBoxManager.shared.hasStreamedText { |
|
|
|
let silenceDuration = now.timeIntervalSince(lastVoiceDetectedTime) |
|
|
|
if silenceDuration >= 2.0 { |
|
|
|
print("AppDelegate: Hands-free trailing silence detected (2.0s). Auto-finalizing.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.stopRecording() |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 3. Absolute maximum duration guard (60s) |
|
|
|
if elapsedSinceStart >= 60.0 { |
|
|
|
print("AppDelegate: Hands-free max recording duration reached (60s). Auto-finalizing.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.stopRecording() |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@objc private func handleDistributedStartRecording() { |
|
|
|
print("AppDelegate: Distributed notification 'com.soniox.voice.start' received.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
if !self.audioRecorder.isRecording { |
|
|
|
self.startRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@objc private func handleDistributedStopRecording() { |
|
|
|
print("AppDelegate: Distributed notification 'com.soniox.voice.stop' received.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.stopRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@objc private func handleDistributedToggleRecording() { |
|
|
|
print("AppDelegate: Distributed notification 'com.soniox.voice.toggle' received.") |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.toggleRecording() |
|
|
|
} |
|
|
|
} |
|
|
|
} |