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
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()
|
|
}
|
|
}
|