import AppKit import ApplicationServices import AVFoundation @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private struct PendingChunk { let url: URL let continuesPhrase: Bool } private struct PendingSnapshot { let urls: [URL] let isFinal: Bool let createdAt: ContinuousClock.Instant } private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let recorder = AudioRecorder() private let hotKeys = HotKeyManager() private let settings = AppSettings.shared private var activeMode: TranscriptionMode? private var pendingChunks: [PendingChunk] = [] private var pendingSnapshots: [PendingSnapshot] = [] private var stableSegments: [URL] = [] private var stableTranscript = StableTranscript() private var processingChunk = false private var stopRequested = false private var stitchNextChunk = false func applicationDidFinishLaunching(_ notification: Notification) { 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))) 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 !stopRequested else { return } do { activeMode = mode lastMode = mode pendingChunks.removeAll() pendingSnapshots.removeAll() stableSegments.forEach { try? FileManager.default.removeItem(at: $0) } stableSegments.removeAll() stableTranscript = StableTranscript() processingChunk = false stopRequested = false stitchNextChunk = false try recorder.start( chunking: mode == .local ? .stablePreviews : .committed, onChunk: { [weak self] chunk in Task { @MainActor in if mode == .local { self?.enqueueStableSegment(chunk.url) } else { self?.enqueueCommitted(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 { if let final = try recorder.stop() { if lastMode == .local { stableSegments.append(final) } else { enqueueCommitted(RecordedChunk(url: final, continuesPhrase: false)) } } if lastMode == .local { Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(100)) self?.enqueueFinalStableSnapshot() } } else if pendingChunks.isEmpty, !processingChunk { completeLiveSession() } } catch { setStatus("ошибка") showError("Диктовка не удалась", error.localizedDescription) } } private func enqueueFinalStableSnapshot() { pendingSnapshots.removeAll(where: { !$0.isFinal }) if stableSegments.isEmpty { completeLiveSession() } else { enqueueStableSnapshot( PendingSnapshot( urls: stableSegments, isFinal: true, createdAt: ContinuousClock.now ) ) } } private func enqueueCommitted(_ chunk: RecordedChunk) { pendingChunks.append(PendingChunk( url: chunk.url, continuesPhrase: chunk.continuesPhrase )) processNextCommittedChunk() } private func enqueueStableSegment(_ url: URL) { stableSegments.append(url) guard !stopRequested else { return } enqueueStableSnapshot( PendingSnapshot( urls: Array(stableSegments.suffix(6)), isFinal: false, createdAt: ContinuousClock.now ) ) } private func enqueueStableSnapshot(_ snapshot: PendingSnapshot) { if snapshot.isFinal { pendingSnapshots.removeAll(where: { !$0.isFinal }) pendingSnapshots.append(snapshot) } else if let index = pendingSnapshots.lastIndex(where: { !$0.isFinal }) { pendingSnapshots[index] = snapshot } else { pendingSnapshots.append(snapshot) } processNextStableSnapshot() } private func processNextCommittedChunk() { 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) 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 !cleaned.isEmpty { let joined = TranscriptJoiner.prepare( cleaned, continuesPrevious: stitchNextChunk, continuesNext: chunk.continuesPhrase, glossary: settings.glossary ) if !joined.isEmpty { try TextInserter.insert(joined + " ") } stitchNextChunk = chunk.continuesPhrase } } catch { showError("Фрагмент диктовки не распознан", error.localizedDescription) } processingChunk = false if pendingChunks.isEmpty { if stopRequested { completeLiveSession() } } else { processNextCommittedChunk() } } } private func processNextStableSnapshot() { guard !processingChunk, !pendingSnapshots.isEmpty else { return } processingChunk = true let snapshot = pendingSnapshots.removeFirst() let mode = activeMode ?? (stopRequested ? lastMode : nil) guard let mode else { processingChunk = false return } Task { do { let processingStartedAt = ContinuousClock.now let wav = try AudioConverter.toWhisperWAV(snapshot.urls) if snapshot.isFinal { saveDiagnosticRecording(wav) } defer { 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 let update = stableTranscript.ingest(cleaned, final: snapshot.isFinal) { try insertStableUpdate(update) NSLog( "WhisperKey stable commit: final=%d chars=%ld total=%@ processing=%@", snapshot.isFinal ? 1 : 0, (update.text as NSString).length, String(describing: snapshot.createdAt.duration(to: .now)), String(describing: processingStartedAt.duration(to: .now)) ) } } catch { if snapshot.isFinal, let update = stableTranscript.ingest("", final: true) { try? insertStableUpdate(update) } if snapshot.isFinal { showError("Финальный фрагмент диктовки не распознан", error.localizedDescription) } else { NSLog("WhisperKey preview failed: %@", error.localizedDescription) } } processingChunk = false if pendingSnapshots.isEmpty { if stopRequested, snapshot.isFinal { completeLiveSession() } } else { processNextStableSnapshot() } } } private func insertStableUpdate(_ update: StableTranscriptUpdate) throws { let text = TranscriptJoiner.prepare( update.text, continuesPrevious: update.continuesPrevious, continuesNext: false, glossary: settings.glossary ) if !text.isEmpty { try TextInserter.insert(text + " ") } } 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 pendingSnapshots.removeAll() stableSegments.forEach { try? FileManager.default.removeItem(at: $0) } stableSegments.removeAll() stableTranscript = StableTranscript() 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 checkLocalEngine() { do { try LocalWhisper.validate(settings: settings) showInfo("Локальный Whisper готов", settings.localModelPath) } catch { showError("Локальный движок не готов", error.localizedDescription) } } @objc private func quit() { 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: Sendable, Equatable { 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 } } }