From 5751743277062d0c3496074c2c5faa545778d82b Mon Sep 17 00:00:00 2001 From: Roman Varlakov Date: Wed, 29 Jul 2026 14:46:12 +0600 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D0=B7=20WhisperKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 + A-B-RESULT.md | 27 ++ Package.swift | 20 ++ README.md | 36 +++ Resources/Info.plist | 28 ++ Resources/com.romanv.WhisperKey.plist | 18 ++ Sources/WhisperKey/AppDelegate.swift | 290 +++++++++++++++++++ Sources/WhisperKey/AppSettings.swift | 63 ++++ Sources/WhisperKey/Audio.swift | 195 +++++++++++++ Sources/WhisperKey/HotKeyManager.swift | 61 ++++ Sources/WhisperKey/SettingsWindow.swift | 93 ++++++ Sources/WhisperKey/TextPipeline.swift | 180 ++++++++++++ Sources/WhisperKey/Transcribers.swift | 183 ++++++++++++ Sources/WhisperKey/main.swift | 7 + Tests/WhisperKeyTests/TextCleanerTests.swift | 102 +++++++ scripts/build-app.sh | 16 + scripts/compare-engines.sh | 38 +++ scripts/install-app.sh | 18 ++ scripts/setup-local-whisper.sh | 50 ++++ 19 files changed, 1436 insertions(+) create mode 100644 .gitignore create mode 100644 A-B-RESULT.md create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Resources/Info.plist create mode 100644 Resources/com.romanv.WhisperKey.plist create mode 100644 Sources/WhisperKey/AppDelegate.swift create mode 100644 Sources/WhisperKey/AppSettings.swift create mode 100644 Sources/WhisperKey/Audio.swift create mode 100644 Sources/WhisperKey/HotKeyManager.swift create mode 100644 Sources/WhisperKey/SettingsWindow.swift create mode 100644 Sources/WhisperKey/TextPipeline.swift create mode 100644 Sources/WhisperKey/Transcribers.swift create mode 100644 Sources/WhisperKey/main.swift create mode 100644 Tests/WhisperKeyTests/TextCleanerTests.swift create mode 100755 scripts/build-app.sh create mode 100755 scripts/compare-engines.sh create mode 100755 scripts/install-app.sh create mode 100755 scripts/setup-local-whisper.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7807623 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.build/ +.swiftpm/ +dist/ + +.env +.env.* +!.env.example + +*.caf +*.wav diff --git a/A-B-RESULT.md b/A-B-RESULT.md new file mode 100644 index 0000000..bbfe43d --- /dev/null +++ b/A-B-RESULT.md @@ -0,0 +1,27 @@ +# A/B: whisper.cpp vs openai/whisper + +Дата: 2026-07-29 + +Один исходный файл: + +`~/Library/Application Support/WhisperKey/last-dictation.wav` + +## whisper.cpp 1.9.1 + +> Ты посмотри там за сборкой, чтобы она нормально шла по ресурсам, по всей хуйне, по монитору. + +## openai/whisper + +Репозиторий: `https://github.com/openai/whisper.git` + +Коммит: `5f86d1d86363843179951550570367b37c5d6f78` + +Модель: `turbo` (`large-v3-turbo.pt`) + +> Ты посмотри там за сборкой, чтобы она нормально шла по ресурсам, по всей хуйне по мониторе. + +## Вывод + +Оба движка на цельной реплике дали практически одинаковую транскрипцию без +вставок про редакторов, корректоров или субтитры. Проблему провоцировала +1,5-секундная нарезка, а не происхождение модели whisper.cpp. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..688b0a6 --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "WhisperKey", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "WhisperKey", + path: "Sources/WhisperKey", + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("AVFoundation"), + .linkedFramework("Carbon"), + .linkedFramework("Security"), + ] + ), + .testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bfddea --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# WhisperKey + +Нативная глобальная диктовка для macOS: + +- `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo; +- `Option+F5` — OpenAI Speech-to-Text API; +- модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5; +- во время длинной диктовки готовые фразы вставляются после паузы около секунды, но не позднее 4 секунд непрерывной речи; +- технические четырёхсекундные фрагменты склеиваются без искусственной точки и заглавной буквы; +- короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать; +- после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную; +- результат очищается и вставляется в активное текстовое поле; +- OpenAI API key хранится в macOS Keychain. + +## Установка + +```bash +chmod +x scripts/*.sh +./scripts/setup-local-whisper.sh +./scripts/install-app.sh +``` + +При первом запуске разрешите: + +1. доступ к микрофону; +2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ». + +Отключите штатное действие F5: + +`Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`. + +API-ключ добавляется через значок WhisperKey в строке меню → «Настройки». + +## Данные + +Локальный режим не отправляет запись в сеть. Облачный режим отправляет временный WAV-файл в OpenAI API; после ответа временные аудиофайлы удаляются. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..e6ad4b8 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + ru + CFBundleExecutable + WhisperKey + CFBundleIdentifier + com.romanv.WhisperKey + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + WhisperKey + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSMicrophoneUsageDescription + WhisperKey использует микрофон для преобразования вашей речи в текст. + + diff --git a/Resources/com.romanv.WhisperKey.plist b/Resources/com.romanv.WhisperKey.plist new file mode 100644 index 0000000..a2e8949 --- /dev/null +++ b/Resources/com.romanv.WhisperKey.plist @@ -0,0 +1,18 @@ + + + + + Label + com.romanv.WhisperKey + ProgramArguments + + /usr/bin/open + -a + /Applications/WhisperKey.app + + RunAtLoad + + ProcessType + Interactive + + diff --git a/Sources/WhisperKey/AppDelegate.swift b/Sources/WhisperKey/AppDelegate.swift new file mode 100644 index 0000000..329459f --- /dev/null +++ b/Sources/WhisperKey/AppDelegate.swift @@ -0,0 +1,290 @@ +import AppKit +import ApplicationServices +import AVFoundation + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private struct PendingChunk { + let url: URL + let continuesPhrase: Bool + } + + 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 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 + } + do { + activeMode = mode + lastMode = mode + pendingChunks.removeAll() + processingChunk = false + stopRequested = false + stitchNextChunk = false + try recorder.start( + 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 { + if let final = try recorder.stop() { + enqueue(RecordedChunk(url: final, continuesPhrase: false)) + } + if pendingChunks.isEmpty, !processingChunk { completeLiveSession() } + } catch { + setStatus("ошибка") + showError("Диктовка не удалась", error.localizedDescription) + } + } + + private func enqueue(_ chunk: RecordedChunk) { + pendingChunks.append(PendingChunk( + url: chunk.url, + continuesPhrase: chunk.continuesPhrase + )) + 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) + 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 { + 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 + 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 { + 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 + } + } +} diff --git a/Sources/WhisperKey/AppSettings.swift b/Sources/WhisperKey/AppSettings.swift new file mode 100644 index 0000000..a0cbc13 --- /dev/null +++ b/Sources/WhisperKey/AppSettings.swift @@ -0,0 +1,63 @@ +import Foundation +import Security + +final class AppSettings: @unchecked Sendable { + static let shared = AppSettings() + private let defaults = UserDefaults.standard + + var localBinaryPath: String { + get { defaults.string(forKey: "localBinaryPath") ?? "\(NSHomeDirectory())/Library/Application Support/WhisperKey/whisper-cli" } + set { defaults.set(newValue, forKey: "localBinaryPath") } + } + var localModelPath: String { + get { defaults.string(forKey: "localModelPath") ?? "\(NSHomeDirectory())/Library/Application Support/WhisperKey/ggml-large-v3-turbo.bin" } + set { defaults.set(newValue, forKey: "localModelPath") } + } + var glossary: String { + get { defaults.string(forKey: "glossary") ?? "CampusAgro, PocketBase, Caddy, SvelteKit, Bitrix, Hetzner, Timeweb, OpenAI, Codex, Claude, VLESS, Reality, Hiddify, CNS8000D, RX8000, RX8500, BX8000, BX8500, BX8500S" } + set { defaults.set(newValue, forKey: "glossary") } + } + var removeFillers: Bool { + get { defaults.object(forKey: "removeFillers") as? Bool ?? true } + set { defaults.set(newValue, forKey: "removeFillers") } + } + var cloudModel: String { + get { defaults.string(forKey: "cloudModel") ?? "gpt-4o-transcribe" } + set { defaults.set(newValue, forKey: "cloudModel") } + } + + func apiKey() -> String? { KeychainStore.read(account: "openai-api-key") } + func saveAPIKey(_ value: String) throws { try KeychainStore.write(value, account: "openai-api-key") } +} + +enum KeychainStore { + private static let service = "com.romanv.WhisperKey" + + static func write(_ value: String, account: String) throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + var add = query + add[kSecValueData as String] = data + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { throw WhisperKeyError.processFailed("Keychain: \(status)") } + } + + static func read(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/WhisperKey/Audio.swift b/Sources/WhisperKey/Audio.swift new file mode 100644 index 0000000..ea1f136 --- /dev/null +++ b/Sources/WhisperKey/Audio.swift @@ -0,0 +1,195 @@ +import AVFoundation +import Foundation + +struct RecordedChunk: Sendable { + let url: URL + let continuesPhrase: Bool +} + +final class AudioRecorder: @unchecked Sendable { + private var engine: AVAudioEngine? + private let lock = NSLock() + private var noSpeechTimeout: DispatchWorkItem? + private var currentURL: URL? + private var outputFile: AVAudioFile? + private var currentFrames: AVAudioFramePosition = 0 + private var currentChunkHasSpeech = false + private var heardSpeech = false + private var silentFrames: AVAudioFramePosition = 0 + private var chunkSilentFrames: AVAudioFramePosition = 0 + private var silenceTriggered = false + private var onChunk: (@Sendable (RecordedChunk) -> Void)? + private var onSilence: (@Sendable () -> Void)? + private var recordingFormat: AVAudioFormat? + + func start( + onChunk: @escaping @Sendable (RecordedChunk) -> Void, + onSilence: @escaping @Sendable () -> Void + ) throws { + guard engine?.isRunning != true else { return } + let engine = AVAudioEngine() + self.engine = engine + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + self.onChunk = onChunk + self.onSilence = onSilence + recordingFormat = format + try beginChunk(format: format) + input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in + self?.consume(buffer, format: format) + } + do { + engine.prepare() + try engine.start() + scheduleNoSpeechTimeout() + } catch { + input.removeTap(onBus: 0) + engine.reset() + self.engine = nil + if let currentURL { try? FileManager.default.removeItem(at: currentURL) } + outputFile = nil + currentURL = nil + throw error + } + } + + func stop() throws -> URL? { + noSpeechTimeout?.cancel() + noSpeechTimeout = nil + if let engine { + if engine.isRunning { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + engine.reset() + self.engine = nil + } + lock.lock() + defer { lock.unlock() } + let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil + if finalURL == nil, let currentURL { + try? FileManager.default.removeItem(at: currentURL) + } + outputFile = nil + currentURL = nil + currentFrames = 0 + currentChunkHasSpeech = false + heardSpeech = false + silentFrames = 0 + chunkSilentFrames = 0 + silenceTriggered = false + return finalURL + } + + private func scheduleNoSpeechTimeout() { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + lock.lock() + let shouldStop = engine?.isRunning == true && !heardSpeech && !silenceTriggered + if shouldStop { silenceTriggered = true } + lock.unlock() + if shouldStop { onSilence?() } + } + noSpeechTimeout = work + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 8, execute: work) + } + + private func beginChunk(format: AVAudioFormat) throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("whisperkey-\(UUID().uuidString).caf") + outputFile = try AVAudioFile(forWriting: url, settings: format.settings) + currentURL = url + currentFrames = 0 + currentChunkHasSpeech = false + chunkSilentFrames = 0 + } + + private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { + lock.lock() + defer { lock.unlock() } + guard let activeOutputFile = outputFile else { return } + try? activeOutputFile.write(from: buffer) + currentFrames += AVAudioFramePosition(buffer.frameLength) + + let rms = bufferRMS(buffer) + if rms > 0.006 { + heardSpeech = true + currentChunkHasSpeech = true + silentFrames = 0 + chunkSilentFrames = 0 + } else if heardSpeech { + silentFrames += AVAudioFramePosition(buffer.frameLength) + if currentChunkHasSpeech { + chunkSilentFrames += AVAudioFramePosition(buffer.frameLength) + } + } + + let silenceLimit = AVAudioFramePosition(format.sampleRate * 5.0) + if heardSpeech, silentFrames >= silenceLimit, !silenceTriggered { + silenceTriggered = true + onSilence?() + return + } + + // Keep enough context for Whisper, but insert completed phrases while + // the user is still dictating. Prefer a short natural pause; cap a + // continuous phrase so the input never stays blank indefinitely. + let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5) + let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0) + let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0) + let reachedPause = + currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary + let reachedMaximum = currentFrames >= maximumChunk + let shouldRotate = currentChunkHasSpeech && (reachedPause || reachedMaximum) + + if shouldRotate, let finishedURL = currentURL { + let finishedHasSpeech = currentChunkHasSpeech + let continuesPhrase = reachedMaximum && !reachedPause + outputFile = nil + do { + try beginChunk(format: format) + if finishedHasSpeech { + onChunk?(RecordedChunk(url: finishedURL, continuesPhrase: continuesPhrase)) + } else { + try? FileManager.default.removeItem(at: finishedURL) + } + } catch { + try? FileManager.default.removeItem(at: finishedURL) + silenceTriggered = true + onSilence?() + } + } + } + + private func bufferRMS(_ buffer: AVAudioPCMBuffer) -> Float { + guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0 } + let samples = channels[0] + var sum: Float = 0 + for index in 0.. URL { + let output = FileManager.default.temporaryDirectory.appendingPathComponent("whisperkey-\(UUID().uuidString).wav") + let ffmpegCandidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] + guard let ffmpeg = ffmpegCandidates.first(where: FileManager.default.isExecutableFile(atPath:)) else { + throw WhisperKeyError.missingDependency("ffmpeg") + } + let process = Process() + process.executableURL = URL(fileURLWithPath: ffmpeg) + process.arguments = ["-hide_banner", "-loglevel", "error", "-y", "-i", input.path, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", output.path] + let stderr = Pipe() + process.standardError = stderr + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let text = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "ffmpeg error" + throw WhisperKeyError.processFailed(text) + } + return output + } +} diff --git a/Sources/WhisperKey/HotKeyManager.swift b/Sources/WhisperKey/HotKeyManager.swift new file mode 100644 index 0000000..86e98fc --- /dev/null +++ b/Sources/WhisperKey/HotKeyManager.swift @@ -0,0 +1,61 @@ +import Carbon + +final class HotKeyManager: @unchecked Sendable { + var onLocal: (@Sendable () -> Void)? + var onCloud: (@Sendable () -> Void)? + + private var localRef: EventHotKeyRef? + private var cloudRef: EventHotKeyRef? + private var handlerRef: EventHandlerRef? + private let signature: OSType = 0x574B4559 // WKEY + + func register() throws { + unregister() + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let status = InstallEventHandler( + GetApplicationEventTarget(), + { _, event, context in + guard let context, let event else { return noErr } + let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() + var id = EventHotKeyID() + GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &id + ) + if id.id == 1 { manager.onLocal?() } + if id.id == 2 { manager.onCloud?() } + return noErr + }, + 1, + &eventType, + Unmanaged.passUnretained(self).toOpaque(), + &handlerRef + ) + guard status == noErr else { throw WhisperKeyError.processFailed("Carbon handler: \(status)") } + + let localID = EventHotKeyID(signature: signature, id: 1) + let cloudID = EventHotKeyID(signature: signature, id: 2) + guard RegisterEventHotKey(UInt32(kVK_F5), 0, localID, GetApplicationEventTarget(), 0, &localRef) == noErr else { + throw WhisperKeyError.processFailed("F5 уже занята. Отключите сочетание системной диктовки.") + } + guard RegisterEventHotKey(UInt32(kVK_F5), UInt32(optionKey), cloudID, GetApplicationEventTarget(), 0, &cloudRef) == noErr else { + throw WhisperKeyError.processFailed("Option+F5 уже занята.") + } + } + + func unregister() { + if let localRef { UnregisterEventHotKey(localRef) } + if let cloudRef { UnregisterEventHotKey(cloudRef) } + if let handlerRef { RemoveEventHandler(handlerRef) } + localRef = nil + cloudRef = nil + handlerRef = nil + } + + deinit { unregister() } +} diff --git a/Sources/WhisperKey/SettingsWindow.swift b/Sources/WhisperKey/SettingsWindow.swift new file mode 100644 index 0000000..4bcf283 --- /dev/null +++ b/Sources/WhisperKey/SettingsWindow.swift @@ -0,0 +1,93 @@ +import AppKit + +@MainActor +final class SettingsWindowController: NSWindowController { + static let shared = SettingsWindowController() + + private let keyField = NSSecureTextField() + private let glossaryField = NSTextField() + private let modelPopup = NSPopUpButton() + private let fillerCheckbox = NSButton(checkboxWithTitle: "Удалять «э», «эм», «как бы», «короче»", target: nil, action: nil) + + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Настройки WhisperKey" + window.center() + super.init(window: window) + buildUI() + } + + required init?(coder: NSCoder) { nil } + + func show() { + loadValues() + showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func buildUI() { + guard let content = window?.contentView else { return } + let grid = NSGridView(views: [ + [label("OpenAI API key"), keyField], + [label("Облачная модель"), modelPopup], + [label("Словарь терминов"), glossaryField], + [NSView(), fillerCheckbox], + ]) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.rowSpacing = 14 + grid.columnSpacing = 16 + grid.column(at: 0).xPlacement = .trailing + modelPopup.addItems(withTitles: ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]) + + let note = NSTextField(wrappingLabelWithString: "F5 — локально. Option+F5 — OpenAI. Вся реплика распознаётся целиком после 5 секунд тишины. Повторное нажатие останавливает её вручную.") + note.textColor = .secondaryLabelColor + note.translatesAutoresizingMaskIntoConstraints = false + let save = NSButton(title: "Сохранить", target: self, action: #selector(save)) + save.keyEquivalent = "\r" + save.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(grid) + content.addSubview(note) + content.addSubview(save) + NSLayoutConstraint.activate([ + grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), + grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), + grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 28), + note.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), + note.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), + note.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 24), + save.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), + save.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), + ]) + } + + private func label(_ value: String) -> NSTextField { + NSTextField(labelWithString: value) + } + + private func loadValues() { + let settings = AppSettings.shared + keyField.stringValue = settings.apiKey() ?? "" + glossaryField.stringValue = settings.glossary + modelPopup.selectItem(withTitle: settings.cloudModel) + fillerCheckbox.state = settings.removeFillers ? .on : .off + } + + @objc private func save() { + do { + let settings = AppSettings.shared + if !keyField.stringValue.isEmpty { try settings.saveAPIKey(keyField.stringValue) } + settings.glossary = glossaryField.stringValue + settings.cloudModel = modelPopup.titleOfSelectedItem ?? "gpt-4o-transcribe" + settings.removeFillers = fillerCheckbox.state == .on + window?.close() + } catch { + let alert = NSAlert(error: error) + alert.runModal() + } + } +} diff --git a/Sources/WhisperKey/TextPipeline.swift b/Sources/WhisperKey/TextPipeline.swift new file mode 100644 index 0000000..ee7610d --- /dev/null +++ b/Sources/WhisperKey/TextPipeline.swift @@ -0,0 +1,180 @@ +import AppKit +import ApplicationServices + +enum TextCleaner { + static func clean(_ input: String, removeFillers: Bool, glossary: String) -> String { + var text = input + .replacingOccurrences(of: "[BLANK_AUDIO]", with: "") + .replacingOccurrences(of: "(тишина)", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if isSilenceHallucination(text) { return "" } + text = stripTrailingSilenceHallucination(from: text) + text = stripEmbeddedSubtitleCredits(from: text) + if removeFillers { + let fillers = [ + #"(?i)(? Bool { + let value = input + .lowercased() + .replacingOccurrences(of: #"[\s.…!?,;:\-–—]+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + let known = [ + "продолжение следует", + "спасибо за просмотр", + "спасибо за внимание", + "подписывайтесь на канал", + "субтитры сделал", + "редактор субтитров", + ] + guard !value.isEmpty else { return false } + let words = value.split(separator: " ") + return (value.hasPrefix("субтитры ") && words.count <= 12) || + known.contains(where: { phrase in + value == phrase || + (value.hasPrefix(phrase + " ") && words.count <= phrase.split(separator: " ").count * 3) + }) + } + + private static func applyGlossary(to input: String, glossary: String) -> String { + var text = input + let aliases: [(String, String)] = [ + (#"(?i)\b(?:compu\s*segro|campus\s*agro|кампус\s*агро|комп\s*агро)\b"#, "CampusAgro"), + (#"(?i)\b(?:pocket\s*base|покет\s*бейс|покетбейс)\b"#, "PocketBase"), + (#"(?i)\b(?:постричь|постгре|постгрес)\s*\.?\s*(?:sql|эс\s*кью\s*эль)\b"#, "PostgreSQL"), + (#"(?i)\b(?:кэдди|кэди)\b"#, "Caddy"), + (#"(?i)\bsvelte\s*kit\b"#, "SvelteKit"), + (#"(?i)\btime\s*web\b"#, "Timeweb"), + (#"(?i)\bopen\s*ai\b"#, "OpenAI"), + (#"(?i)\bэлектро\s+энцефалография\b"#, "электроэнцефалография"), + (#"(?i)\bдоступ\s+примечател(?:ь|ьность)?\b"#, "достопримечательность"), + (#"(?i)\bэкзист(?:\s+компотенциальный)?\b"#, "экзистенциальный"), + ] + for (pattern, replacement) in aliases { + text = text.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression) + } + let terms = glossary.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + for term in terms { + text = text.replacingOccurrences( + of: #"(?i)(? String { + input.replacingOccurrences( + of: #"(?i)(?:[.!?]\s*)?(?:спасибо(?:\s+за\s+(?:просмотр|внимание))?|продолжение\s+следует|подпишись(?:\s+на\s+канал)?|субтитры\s+(?:создавал[аи]?|сделал[аи]?|подготовил[аи]?|редактировал[аи]?|редактор)\s+[\p{L}\p{N}_-]+)[.!?…\s]*$"#, + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func stripEmbeddedSubtitleCredits(from input: String) -> String { + input.replacingOccurrences( + of: #"(?i)(?:редактор\s+субтитров|корректор)\s+[A-ZА-ЯЁ]\.?\s*[\p{L}-]+[.!?…,\s]*"#, + with: "", + options: .regularExpression + ) + } + + private static func normalize(_ value: String) -> String { + let cleaned = value + .replacingOccurrences(of: #"[ \t]{2,}"#, with: " ", options: .regularExpression) + .replacingOccurrences(of: #" +([,.!?;:])"#, with: "$1", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = cleaned.first else { return cleaned } + return first.uppercased() + cleaned.dropFirst() + } +} + +enum TranscriptJoiner { + static func prepare( + _ input: String, + continuesPrevious: Bool, + continuesNext: Bool, + glossary: String + ) -> String { + var text = input.trimmingCharacters(in: .whitespacesAndNewlines) + if continuesPrevious { + text = lowercaseLeadingWord(in: text, glossary: glossary) + } + if continuesNext { + text = text.replacingOccurrences( + of: #"[.!?…]+$"#, + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + private static func lowercaseLeadingWord(in input: String, glossary: String) -> String { + guard let range = input.range( + of: #"^[\p{L}\p{N}_+#.-]+"#, + options: .regularExpression + ) else { return input } + let word = String(input[range]) + let protectedTerms = glossary + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + if protectedTerms.contains(word.lowercased()) { return input } + + let letters = word.filter(\.isLetter) + guard let first = letters.first, + first.isUppercase, + letters.dropFirst().contains(where: \.isLowercase) else { + return input + } + return word.prefix(1).lowercased() + input.dropFirst(word.prefix(1).count) + } +} + +enum TextInserter { + static func insert(_ text: String) throws { + guard AXIsProcessTrusted() else { + throw WhisperKeyError.processFailed("Разрешите WhisperKey управлять компьютером: Системные настройки → Конфиденциальность и безопасность → Универсальный доступ.") + } + let pasteboard = NSPasteboard.general + let oldItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in + var values: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { values[type] = data } + } + return values + } + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + let source = CGEventSource(stateID: .hidSystemState) + let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true) + let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) + down?.flags = .maskCommand + up?.flags = .maskCommand + down?.post(tap: .cghidEventTap) + up?.post(tap: .cghidEventTap) + + if let oldItems { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + let restored = oldItems.map { values -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in values { item.setData(data, forType: type) } + return item + } + pasteboard.clearContents() + pasteboard.writeObjects(restored) + } + } + } +} diff --git a/Sources/WhisperKey/Transcribers.swift b/Sources/WhisperKey/Transcribers.swift new file mode 100644 index 0000000..5bf1129 --- /dev/null +++ b/Sources/WhisperKey/Transcribers.swift @@ -0,0 +1,183 @@ +import Foundation + +enum LocalWhisper { + private static let server = LocalWhisperServer() + + static func validate(settings: AppSettings) throws { + guard FileManager.default.isExecutableFile(atPath: settings.localBinaryPath) else { + throw WhisperKeyError.missingDependency(settings.localBinaryPath) + } + guard FileManager.default.fileExists(atPath: settings.localModelPath) else { + throw WhisperKeyError.missingDependency(settings.localModelPath) + } + guard FileManager.default.fileExists(atPath: vadModelPath) else { + throw WhisperKeyError.missingDependency(vadModelPath) + } + guard FileManager.default.isExecutableFile(atPath: serverBinaryPath) else { + throw WhisperKeyError.missingDependency(serverBinaryPath) + } + } + + private static let vadModelPath = + "\(NSHomeDirectory())/Library/Application Support/WhisperKey/ggml-silero-v6.2.0.bin" + private static let serverBinaryPath = + "\(NSHomeDirectory())/Library/Application Support/WhisperKey/whisper-server" + + static func preload(settings: AppSettings) async throws { + try validate(settings: settings) + try await server.ensureRunning( + binaryPath: serverBinaryPath, + modelPath: settings.localModelPath, + vadModelPath: vadModelPath + ) + } + + static func transcribe(wav: URL, settings: AppSettings) async throws -> String { + try validate(settings: settings) + return try await server.transcribe( + wav: wav, + binaryPath: serverBinaryPath, + modelPath: settings.localModelPath, + vadModelPath: vadModelPath + ) + } +} + +private actor LocalWhisperServer { + private let baseURL = URL(string: "http://127.0.0.1:8178")! + private var process: Process? + + func ensureRunning(binaryPath: String, modelPath: String, vadModelPath: String) async throws { + if await isHealthy() { return } + if process?.isRunning != true { + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = [ + "-m", modelPath, + "--host", "127.0.0.1", + "--port", "8178", + "-l", "ru", + "-nt", + "-nf", + "-sns", + "--no-speech-thold", "0.35", + "--vad", + "--vad-model", vadModelPath, + "--vad-threshold", "0.55", + "--vad-min-speech-duration-ms", "250", + "--vad-min-silence-duration-ms", "300", + "--vad-speech-pad-ms", "100", + "-t", "8", + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + self.process = process + } + + for _ in 0..<300 { + if await isHealthy() { return } + try await Task.sleep(for: .milliseconds(100)) + } + throw WhisperKeyError.processFailed("Локальный Whisper не успел загрузить модель.") + } + + func transcribe( + wav: URL, + binaryPath: String, + modelPath: String, + vadModelPath: String + ) async throws -> String { + try await ensureRunning( + binaryPath: binaryPath, + modelPath: modelPath, + vadModelPath: vadModelPath + ) + + let boundary = "WhisperKey-\(UUID().uuidString)" + var request = URLRequest(url: baseURL.appendingPathComponent("inference")) + request.httpMethod = "POST" + request.timeoutInterval = 60 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + body.appendField("language", value: "ru", boundary: boundary) + body.appendField("temperature", value: "0", boundary: boundary) + body.appendField("no_speech_thold", value: "0.35", boundary: boundary) + body.appendField("response_format", value: "json", boundary: boundary) + body.appendFile( + "file", + filename: "dictation.wav", + mime: "audio/wav", + data: try Data(contentsOf: wav), + boundary: boundary + ) + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw WhisperKeyError.badResponse(String(data: data, encoding: .utf8) ?? "Ошибка локального Whisper.") + } + return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text + } + + private func isHealthy() async -> Bool { + var request = URLRequest(url: baseURL.appendingPathComponent("health")) + request.timeoutInterval = 0.5 + guard let (_, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse else { return false } + return (200..<300).contains(http.statusCode) + } +} + +enum OpenAITranscriber { + static func transcribe(wav: URL, settings: AppSettings) async throws -> String { + guard let key = settings.apiKey(), !key.isEmpty else { throw WhisperKeyError.missingAPIKey } + let boundary = "WhisperKey-\(UUID().uuidString)" + var request = URLRequest(url: URL(string: "https://api.openai.com/v1/audio/transcriptions")!) + request.httpMethod = "POST" + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + body.appendField("model", value: settings.cloudModel, boundary: boundary) + body.appendField("language", value: "ru", boundary: boundary) + body.appendField("temperature", value: "0", boundary: boundary) + body.appendField( + "prompt", + value: "Дословная русская транскрипция. Не добавляй слова, которых нет в аудио. Термины: \(settings.glossary)", + boundary: boundary + ) + body.appendFile("file", filename: "dictation.wav", mime: "audio/wav", data: try Data(contentsOf: wav), boundary: boundary) + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + .flatMap { $0["error"] as? [String: Any] }?["message"] as? String + throw WhisperKeyError.badResponse(message ?? "OpenAI API вернул ошибку.") + } + let result = try JSONDecoder().decode(TranscriptionResponse.self, from: data) + return result.text + } +} + +private struct TranscriptionResponse: Decodable { let text: String } + +private extension Data { + mutating func appendField(_ name: String, value: String, boundary: String) { + append("--\(boundary)\r\n".data(using: .utf8)!) + append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) + append("\(value)\r\n".data(using: .utf8)!) + } + + mutating func appendFile(_ name: String, filename: String, mime: String, data: Data, boundary: String) { + append("--\(boundary)\r\n".data(using: .utf8)!) + append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + append("Content-Type: \(mime)\r\n\r\n".data(using: .utf8)!) + append(data) + append("\r\n".data(using: .utf8)!) + } +} diff --git a/Sources/WhisperKey/main.swift b/Sources/WhisperKey/main.swift new file mode 100644 index 0000000..c39deb8 --- /dev/null +++ b/Sources/WhisperKey/main.swift @@ -0,0 +1,7 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/Tests/WhisperKeyTests/TextCleanerTests.swift b/Tests/WhisperKeyTests/TextCleanerTests.swift new file mode 100644 index 0000000..76a3db9 --- /dev/null +++ b/Tests/WhisperKeyTests/TextCleanerTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import WhisperKey + +final class TextCleanerTests: XCTestCase { + func testGlossaryAndFillers() { + let result = TextCleaner.clean( + "Эм, проверка CompuSegro, pocketbase и caddy, короче, работает.", + removeFillers: true, + glossary: "CampusAgro, PocketBase, Caddy" + ) + XCTAssertEqual(result, "Проверка CampusAgro, PocketBase и Caddy, работает.") + } + + func testPreservesTextWhenFillerRemovalDisabled() { + let result = TextCleaner.clean( + "Ну вот, PocketBase работает.", + removeFillers: false, + glossary: "PocketBase" + ) + XCTAssertEqual(result, "Ну вот, PocketBase работает.") + } + + func testDropsCommonSilenceHallucinations() { + XCTAssertEqual( + TextCleaner.clean( + "Продолжение следует... Продолжение следует...", + removeFillers: true, + glossary: "" + ), + "" + ) + } + + func testStripsHallucinatedEndingAndAppliesPhoneticAliases() { + XCTAssertEqual( + TextCleaner.clean( + "комп Агро, Покетбейс, Постричь. SQL и Кэдди. Спасибо за просмотр.", + removeFillers: true, + glossary: "CampusAgro, PocketBase, PostgreSQL, Caddy" + ), + "CampusAgro, PocketBase, PostgreSQL и Caddy" + ) + } + + func testDropsSubtitleCreditHallucination() { + XCTAssertEqual( + TextCleaner.clean( + "Субтитры создавал DimaTorzok", + removeFillers: true, + glossary: "" + ), + "" + ) + } + + func testRemovesEmbeddedSubtitleCreditsWithoutDroppingSpeech() { + XCTAssertEqual( + TextCleaner.clean( + "Ну запускай похуй. Редактор субтитров А.Семкин Корректор А.Егорова Сбор лидов.", + removeFillers: true, + glossary: "" + ), + "Ну запускай похуй. Сбор лидов." + ) + } + + func testJoinsTechnicalChunksWithoutArtificialSentenceBoundary() { + XCTAssertEqual( + TranscriptJoiner.prepare( + "Модель понимает.", + continuesPrevious: true, + continuesNext: true, + glossary: "" + ), + "модель понимает" + ) + } + + func testPreservesGlossaryCapitalizationWhenJoiningChunks() { + XCTAssertEqual( + TranscriptJoiner.prepare( + "PocketBase работает.", + continuesPrevious: true, + continuesNext: false, + glossary: "CampusAgro, PocketBase, Caddy" + ), + "PocketBase работает." + ) + } + + func testKeepsSentenceStartAfterRealPause() { + XCTAssertEqual( + TranscriptJoiner.prepare( + "Это новое предложение.", + continuesPrevious: false, + continuesNext: false, + glossary: "" + ), + "Это новое предложение." + ) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..314afde --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,16 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_DIR=${0:A:h:h} +APP_DIR="$PROJECT_DIR/dist/WhisperKey.app" + +cd "$PROJECT_DIR" +swift build -c release + +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" +cp "$PROJECT_DIR/.build/release/WhisperKey" "$APP_DIR/Contents/MacOS/WhisperKey" +cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist" +codesign --force --deep --sign - "$APP_DIR" + +echo "$APP_DIR" diff --git a/scripts/compare-engines.sh b/scripts/compare-engines.sh new file mode 100755 index 0000000..a29a631 --- /dev/null +++ b/scripts/compare-engines.sh @@ -0,0 +1,38 @@ +#!/bin/zsh +set -euo pipefail + +SUPPORT_DIR="$HOME/Library/Application Support/WhisperKey" +WAV_PATH="${1:-$SUPPORT_DIR/last-dictation.wav}" + +if [[ ! -f "$WAV_PATH" ]]; then + echo "Нет записи для сравнения: $WAV_PATH" >&2 + exit 1 +fi + +echo "=== whisper.cpp ===" +"$SUPPORT_DIR/whisper-cli" \ + -m "$SUPPORT_DIR/ggml-large-v3-turbo.bin" \ + -f "$WAV_PATH" \ + -l ru \ + --no-timestamps \ + --no-prints \ + --no-fallback \ + --max-context 0 + +echo +echo "=== openai/whisper ===" +"$SUPPORT_DIR/openai-whisper-venv/bin/python" - "$WAV_PATH" "$SUPPORT_DIR/openai-models" <<'PY' +import sys +import whisper + +audio_path, model_dir = sys.argv[1], sys.argv[2] +model = whisper.load_model("turbo", download_root=model_dir) +result = model.transcribe( + audio_path, + language="ru", + temperature=0, + condition_on_previous_text=False, + fp16=False, +) +print(result["text"].strip()) +PY diff --git a/scripts/install-app.sh b/scripts/install-app.sh new file mode 100755 index 0000000..f86b629 --- /dev/null +++ b/scripts/install-app.sh @@ -0,0 +1,18 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_DIR=${0:A:h:h} +"$PROJECT_DIR/scripts/build-app.sh" + +ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app" + +LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents" +LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist" +mkdir -p "$LOGIN_AGENT_DIR" +cp "$PROJECT_DIR/Resources/com.romanv.WhisperKey.plist" "$LOGIN_AGENT" +launchctl bootout "gui/$(id -u)" "$LOGIN_AGENT" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$LOGIN_AGENT" + +open "/Applications/WhisperKey.app" + +echo "Installed: /Applications/WhisperKey.app" diff --git a/scripts/setup-local-whisper.sh b/scripts/setup-local-whisper.sh new file mode 100755 index 0000000..82e8bc7 --- /dev/null +++ b/scripts/setup-local-whisper.sh @@ -0,0 +1,50 @@ +#!/bin/zsh +set -euo pipefail + +SUPPORT_DIR="$HOME/Library/Application Support/WhisperKey" +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +mkdir -p "$SUPPORT_DIR" +git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git "$TEMP_DIR/whisper.cpp" +cmake -S "$TEMP_DIR/whisper.cpp" -B "$TEMP_DIR/whisper.cpp/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DWHISPER_METAL=ON \ + -DWHISPER_COREML=OFF \ + -DWHISPER_BUILD_SERVER=ON +cmake --build "$TEMP_DIR/whisper.cpp/build" --config Release --target whisper-cli whisper-server -j 8 +cp "$TEMP_DIR/whisper.cpp/build/bin/whisper-cli" "$SUPPORT_DIR/whisper-cli" +cp "$TEMP_DIR/whisper.cpp/build/bin/whisper-server" "$SUPPORT_DIR/whisper-server" +chmod 755 "$SUPPORT_DIR/whisper-cli" "$SUPPORT_DIR/whisper-server" + +MODEL_FILE="ggml-large-v3-turbo.bin" +MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/$MODEL_FILE" +MODEL_SHA256="1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69" +if [[ ! -s "$SUPPORT_DIR/$MODEL_FILE" ]]; then + curl --fail --location --progress-bar "$MODEL_URL" --output "$SUPPORT_DIR/$MODEL_FILE" +fi + +ACTUAL_SHA256=$(shasum -a 256 "$SUPPORT_DIR/$MODEL_FILE" | awk '{print $1}') +if [[ "$ACTUAL_SHA256" != "$MODEL_SHA256" ]]; then + echo "Model checksum mismatch: $ACTUAL_SHA256" >&2 + exit 1 +fi + +VAD_FILE="ggml-silero-v6.2.0.bin" +VAD_URL="https://huggingface.co/ggml-org/whisper-vad/resolve/main/$VAD_FILE" +VAD_SHA256="2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987" +if [[ ! -s "$SUPPORT_DIR/$VAD_FILE" ]]; then + curl --fail --location --progress-bar "$VAD_URL" --output "$SUPPORT_DIR/$VAD_FILE" +fi + +ACTUAL_VAD_SHA256=$(shasum -a 256 "$SUPPORT_DIR/$VAD_FILE" | awk '{print $1}') +if [[ "$ACTUAL_VAD_SHA256" != "$VAD_SHA256" ]]; then + echo "VAD checksum mismatch: $ACTUAL_VAD_SHA256" >&2 + exit 1 +fi + +echo "Local Whisper installed:" +echo " $SUPPORT_DIR/whisper-cli" +echo " $SUPPORT_DIR/whisper-server" +echo " $SUPPORT_DIR/$MODEL_FILE"