import Cocoa import AVFoundation import ApplicationServices public final class AppDelegate: NSObject, NSApplicationDelegate { private var statusBarController: StatusBarController! private var audioRecorder = AudioRecorder() private var activeSession: SonioxLiveSession? private var isBusyFinalizing = false private var currentAudioLevel: Float = 0.0 private var latestPartialText: String? = nil private var pendingStopWorkItem: DispatchWorkItem? = nil private var isNetworkDegraded: Bool = false private var lastRecordedAudio: Data = Data() public func applicationDidFinishLaunching(_ notification: Notification) { if UserDefaults.standard.object(forKey: "SonioxPlaySounds") == nil { UserDefaults.standard.set(true, forKey: "SonioxPlaySounds") } statusBarController = StatusBarController() statusBarController.onToggleRecording = { [weak self] in self?.toggleRecording() } // Setup Hotkey manager callbacks (Push-To-Talk & Toggle) HotkeyManager.shared.onHotkeyPressed = { [weak self] in guard let self = self else { return } self.pendingStopWorkItem?.cancel() self.pendingStopWorkItem = nil if HotkeyManager.shared.currentMode == .pushToTalk { if !self.audioRecorder.isRecording { self.startRecording() } } else { self.toggleRecording() } } HotkeyManager.shared.onHotkeyReleased = { [weak self] in guard let self = self else { return } if HotkeyManager.shared.currentMode == .pushToTalk { if self.audioRecorder.isRecording { self.pendingStopWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if self.audioRecorder.isRecording { self.stopRecording() } } 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) } } } HotkeyManager.shared.registerHotkeys() // Listen to live audio levels & update HUD audioRecorder.onAudioLevelUpdate = { [weak self] level in guard let self = self else { return } self.currentAudioLevel = level if self.audioRecorder.isRecording { HUDOverlayController.shared.show(state: .recording(level: level, liveText: self.latestPartialText, isLagging: self.isNetworkDegraded)) } } // Stream audio chunk live directly to active Soniox session in real-time audioRecorder.onAudioChunkAvailable = { [weak self] chunk in self?.activeSession?.sendAudioChunk(chunk) } // Pre-warm the background WebSocket session for 0ms startup delay SonioxSessionPool.shared.prewarmNextSession() // Check permissions on start checkInitialPermissions() } private func checkInitialPermissions() { // 1. Microphone audioRecorder.requestMicrophonePermission { granted in if !granted { print("Microphone permission not granted yet.") } } // 2. Accessibility if !AXIsProcessTrusted() { let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary AXIsProcessTrustedWithOptions(options) } } private func playSystemSound(name: String) { if SonioxSettings.shared.playSounds { NSSound(named: NSSound.Name(name))?.play() } } public func toggleRecording() { if audioRecorder.isRecording { stopRecording() } else { startRecording() } } public func startRecording() { if audioRecorder.isRecording || isBusyFinalizing { return } latestPartialText = nil isNetworkDegraded = false lastRecordedAudio.removeAll() // 1. Find or focus an input box in the active window before user starts speaking InputBoxManager.shared.ensureFocusedInputBox() // 2. Acquire pre-warmed / fast-connected WebSocket session let session = SonioxSessionPool.shared.acquireSession() self.activeSession = session // Handle network degradation / packet loss warning session.onNetworkDegraded = { [weak self] degraded in guard let self = self else { return } self.isNetworkDegraded = degraded if self.audioRecorder.isRecording { HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: self.latestPartialText, isLagging: degraded)) } } // Handle streaming delta text (incremental committed words) session.onCommittedDelta = { [weak self] delta in guard let self = self else { return } // 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 } InputBoxManager.shared.streamDelta(text: delta) } session.onPartialText = { [weak self] partial in guard let self = self else { return } self.latestPartialText = partial if self.audioRecorder.isRecording { HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: partial, isLagging: self.isNetworkDegraded)) } } session.onFinalResult = { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } self.handleFinalResult(result) } } do { try audioRecorder.startRecording() playSystemSound(name: "Tink") statusBarController.updateIcon(state: .recording) statusBarController.buildMenu(isRecording: true) HUDOverlayController.shared.show(state: .recording(level: 0.1, liveText: nil, isLagging: false)) } catch { print("Failed to start audio recording:", error) activeSession?.cancel() activeSession = nil HUDOverlayController.shared.show(state: .error(message: "عدم دسترسی به میکروفون")) } } public func stopRecording() { guard audioRecorder.isRecording else { return } let pcmData = audioRecorder.stopRecording() self.lastRecordedAudio = pcmData 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 4.0s DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { [weak self] in guard let self = self, self.isBusyFinalizing else { return } print("AppDelegate: Finalize safety watchdog triggered — recovering transcript.") self.activeSession?.completeWithCurrentText() } activeSession?.finalizeStream() } private func handleFinalResult(_ result: Result) { self.activeSession = nil self.statusBarController.updateIcon(state: .idle) var rawText = "" switch result { case .success(let text): rawText = text.trimmingCharacters(in: .whitespacesAndNewlines) case .failure(let error): print("Soniox primary finalize error:", error) } // Check if smart audio recovery is needed: // If text is empty or we experienced network failure, and we captured substantial audio (> 0.8s) let needsRetry = (rawText.isEmpty || self.isNetworkDegraded) && self.lastRecordedAudio.count > 25600 if needsRetry { print("AppDelegate: Network drop or truncated output detected. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") HUDOverlayController.shared.show(state: .retrying(message: "اینترنت ضعیف... در حال بازیابی صوت")) let audioCopy = self.lastRecordedAudio let hints = SonioxSettings.shared.language.hints SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, onProgress: { progress in HUDOverlayController.shared.show(state: .recording(level: 0.2, liveText: progress, isLagging: true)) }) { [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) } } return } self.isBusyFinalizing = false deliverOutputText(rawText) } private func deliverOutputText(_ text: String) { guard !text.isEmpty else { HUDOverlayController.shared.hide() return } // 1. Check for Executive Voice Slash Commands if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: text) { HUDOverlayController.shared.show(state: .success(text: "دستور صوتی")) VoiceCommandExecutor.shared.execute(rawText: text) return // STRICT: Slash commands are executed, never pasted! } // 2. Normal dictation processing & paste let processed = TextProcessor.shared.process(text: text) if !processed.isEmpty { VoiceCommandLogger.shared.logDictation(rawSpeech: text, processed: processed) HistoryManager.shared.addEntry(processed) InputBoxManager.shared.commitFinalText(processedText: processed, addTrailingSpace: SonioxSettings.shared.addTrailingSpace) HUDOverlayController.shared.show(state: .success(text: processed)) } else { HUDOverlayController.shared.hide() } } public func applicationWillTerminate(_ notification: Notification) { HotkeyManager.shared.unregisterHotkeys() } }