import AppKit import ApplicationServices import AVFoundation @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private struct PendingChunk { let url: URL let continuesPhrase: Bool let isFinalSession: Bool } private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let recorder = AudioRecorder() private let hotKeys = HotKeyManager() private let composition = NativeCompositionBridge() private let settings = AppSettings.shared private var activeMode: TranscriptionMode? private var startingSession = false private var pendingChunks: [PendingChunk] = [] private var processingChunk = false private var stopRequested = false private var stitchNextChunk = false private var nativeSession = false private var nativeDraft = "" func applicationDidFinishLaunching(_ notification: Notification) { composition.onAvailabilityChange = { [weak self] in self?.rebuildMenu() } composition.start() configureStatusItem() requestPermissions() hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } } hotKeys.onCloud = { [weak self] in Task { @MainActor in await self?.toggle(.cloud) } } do { try hotKeys.register() setStatus("загружаю модель…") } catch { showError("Не удалось зарегистрировать F5", error.localizedDescription) } Task { do { try await LocalWhisper.preload(settings: settings) if activeMode == nil { setStatus("готово") } } catch { if activeMode == nil { setStatus("ошибка модели") } } } NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleWake), name: NSWorkspace.didWakeNotification, object: nil ) } private func configureStatusItem() { statusItem.button?.image = NSImage(systemSymbolName: "waveform.badge.mic", accessibilityDescription: "WhisperKey") statusItem.button?.imagePosition = .imageLeading rebuildMenu() } private func rebuildMenu() { let menu = NSMenu() let state = NSMenuItem(title: "WhisperKey — готов", action: nil, keyEquivalent: "") state.tag = 100 state.isEnabled = false menu.addItem(state) menu.addItem(.separator()) menu.addItem(item("F5 — локальная диктовка", #selector(startLocal))) menu.addItem(item("⌥F5 — OpenAI", #selector(startCloud))) menu.addItem(.separator()) menu.addItem(item("Настройки…", #selector(openSettings))) menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine))) let nativeState: String if composition.isAvailable { nativeState = "Нативный ввод — активен" } else if composition.isInstalled { nativeState = "Нативный ввод — готов" } else { nativeState = "Установить нативный ввод…" } menu.addItem(item(nativeState, #selector(openInputSources))) menu.addItem(.separator()) menu.addItem(item("Выход", #selector(quit), key: "q")) statusItem.menu = menu } private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: key) item.target = self return item } private func setStatus(_ text: String) { statusItem.button?.title = activeMode == nil ? "" : " \(text)" statusItem.menu?.item(withTag: 100)?.title = "WhisperKey — \(text)" } private func requestPermissions() { AVCaptureDevice.requestAccess(for: .audio) { _ in } let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } private func toggle(_ mode: TranscriptionMode) async { if activeMode != nil { await stopAndTranscribe() return } guard !startingSession else { return } startingSession = true defer { startingSession = false } var nativeSelected = composition.activateForDictation() if !nativeSelected { try? await Task.sleep(for: .milliseconds(250)) nativeSelected = composition.activateForDictation() } try? await Task.sleep(for: .milliseconds(180)) do { activeMode = mode lastMode = mode pendingChunks.removeAll() processingChunk = false stopRequested = false stitchNextChunk = false nativeDraft = "" nativeSession = nativeSelected && composition.isAvailable if nativeSession { composition.cancel(restoreInputSource: false) } else { composition.restoreInputSource() } try recorder.start( chunkTiming: nativeSession ? .nativeDraft : .committed, onChunk: { [weak self] chunk in Task { @MainActor in self?.enqueue(chunk) } }, onSilence: { [weak self] in Task { @MainActor in self?.finishLiveSession() } } ) setStatus(mode == .local ? "слушаю локально…" : "слушаю для OpenAI…") DictationSounds.playStart() } catch { activeMode = nil showError("Не удалось начать запись", error.localizedDescription) } } private func stopAndTranscribe() async { finishLiveSession() } private func finishLiveSession() { guard activeMode != nil, !stopRequested else { return } stopRequested = true activeMode = nil DictationSounds.playStop() setStatus("обрабатываю…") do { let result = try recorder.stop() if nativeSession, let fullSession = result.fullSessionURL { if let finalChunk = result.finalChunkURL { try? FileManager.default.removeItem(at: finalChunk) } enqueue( RecordedChunk(url: fullSession, continuesPhrase: false), isFinalSession: true ) } else { if let fullSession = result.fullSessionURL { try? FileManager.default.removeItem(at: fullSession) } if let finalChunk = result.finalChunkURL { enqueue(RecordedChunk(url: finalChunk, continuesPhrase: false)) } } if pendingChunks.isEmpty, !processingChunk { if nativeSession { composition.cancel() } completeLiveSession() } } catch { composition.cancel() setStatus("ошибка") showError("Диктовка не удалась", error.localizedDescription) } } private func enqueue(_ chunk: RecordedChunk, isFinalSession: Bool = false) { pendingChunks.append(PendingChunk( url: chunk.url, continuesPhrase: chunk.continuesPhrase, isFinalSession: isFinalSession )) processNextChunk() } private func processNextChunk() { guard !processingChunk, !pendingChunks.isEmpty else { return } processingChunk = true let chunk = pendingChunks.removeFirst() let recording = chunk.url let mode = activeMode ?? (stopRequested ? lastMode : nil) guard let mode else { processingChunk = false try? FileManager.default.removeItem(at: recording) return } Task { do { let wav = try AudioConverter.toWhisperWAV(recording) if chunk.isFinalSession { saveDiagnosticRecording(wav) } defer { try? FileManager.default.removeItem(at: recording) try? FileManager.default.removeItem(at: wav) } let raw: String switch mode { case .local: raw = try await LocalWhisper.transcribe(wav: wav, settings: settings) case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings) } let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary) if chunk.isFinalSession { if cleaned.isEmpty { composition.cancel() } else if !composition.commit(cleaned + " ") { try TextInserter.insert(cleaned + " ") } nativeDraft = "" stitchNextChunk = false } else if !cleaned.isEmpty { let joined = TranscriptJoiner.prepare( cleaned, continuesPrevious: stitchNextChunk, continuesNext: chunk.continuesPhrase, glossary: settings.glossary ) if !joined.isEmpty { if nativeSession { nativeDraft += (nativeDraft.isEmpty ? "" : " ") + joined _ = composition.updateDraft(nativeDraft) } else { try TextInserter.insert(joined + " ") } } stitchNextChunk = chunk.continuesPhrase } } catch { if chunk.isFinalSession, !nativeDraft.isEmpty { if !composition.commit(nativeDraft + " ") { try? TextInserter.insert(nativeDraft + " ") } } showError( chunk.isFinalSession ? "Финальная диктовка не распознана" : "Фрагмент диктовки не распознан", error.localizedDescription ) } processingChunk = false if pendingChunks.isEmpty { if stopRequested { completeLiveSession() } } else { processNextChunk() } } } private func saveDiagnosticRecording(_ wav: URL) { let directory = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask )[0].appendingPathComponent("WhisperKey", isDirectory: true) let destination = directory.appendingPathComponent("last-dictation.wav") try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try? FileManager.default.removeItem(at: destination) try? FileManager.default.copyItem(at: wav, to: destination) } private var lastMode: TranscriptionMode? private func completeLiveSession() { processingChunk = false stopRequested = false lastMode = nil stitchNextChunk = false nativeSession = false nativeDraft = "" setStatus("готово") } @objc private func startLocal() { Task { await toggle(.local) } } @objc private func startCloud() { Task { await toggle(.cloud) } } @objc private func openSettings() { SettingsWindowController.shared.show() } @objc private func openInputSources() { let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")! NSWorkspace.shared.open(url) } @objc private func checkLocalEngine() { do { try LocalWhisper.validate(settings: settings) showInfo("Локальный Whisper готов", settings.localModelPath) } catch { showError("Локальный движок не готов", error.localizedDescription) } } @objc private func quit() { composition.cancel() NSApp.terminate(nil) } @objc private func handleWake() { do { try hotKeys.register() setStatus("готово") } catch { setStatus("F5 недоступна") } } private func showError(_ title: String, _ message: String) { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = title alert.informativeText = message alert.runModal() } private func showInfo(_ title: String, _ message: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.runModal() } } enum TranscriptionMode { case local case cloud } private enum DictationSounds { private static let directory = "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system" static func playStart() { play("begin_record.caf") } static func playStop() { play("end_record.caf") } private static func play(_ filename: String) { let url = URL(fileURLWithPath: directory).appendingPathComponent(filename) NSSound(contentsOf: url, byReference: true)?.play() } } enum WhisperKeyError: LocalizedError { case emptyTranscript case missingDependency(String) case processFailed(String) case missingAPIKey case badResponse(String) var errorDescription: String? { switch self { case .emptyTranscript: return "Речь не распознана." case .missingDependency(let value): return "Не найдено: \(value)" case .processFailed(let value): return value case .missingAPIKey: return "Добавьте OpenAI API key в настройках WhisperKey." case .badResponse(let value): return value } } }