وویس تایپینگ سانی اوکس مک
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

268 lines
11 KiB

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<String, Error>) {
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()
}
}