Первый релиз WhisperKey
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
*.caf
|
||||||
|
*.wav
|
||||||
@@ -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.
|
||||||
@@ -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"])
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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; после ответа временные аудиофайлы удаляются.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>ru</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>WhisperKey</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.romanv.WhisperKey</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>WhisperKey</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>WhisperKey использует микрофон для преобразования вашей речи в текст.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.romanv.WhisperKey</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/open</string>
|
||||||
|
<string>-a</string>
|
||||||
|
<string>/Applications/WhisperKey.app</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Interactive</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<Int(buffer.frameLength) {
|
||||||
|
let value = samples[index]
|
||||||
|
sum += value * value
|
||||||
|
}
|
||||||
|
return sqrt(sum / Float(buffer.frameLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AudioConverter {
|
||||||
|
static func toWhisperWAV(_ input: URL) throws -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HotKeyManager>.fromOpaque(context).takeUnretainedValue()
|
||||||
|
var id = EventHotKeyID()
|
||||||
|
GetEventParameter(
|
||||||
|
event,
|
||||||
|
EventParamName(kEventParamDirectObject),
|
||||||
|
EventParamType(typeEventHotKeyID),
|
||||||
|
nil,
|
||||||
|
MemoryLayout<EventHotKeyID>.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() }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)(?<![\p{L}])(?:э+|эм+|мм+)(?![\p{L}])[, ]*"#,
|
||||||
|
#"(?i)(?<![\p{L}])(?:ну\s+вот|как\s+бы|короче)(?![\p{L}])[, ]*"#,
|
||||||
|
]
|
||||||
|
for pattern in fillers {
|
||||||
|
text = text.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = applyGlossary(to: text, glossary: glossary)
|
||||||
|
return normalize(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isSilenceHallucination(_ input: String) -> 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)(?<![\p{L}\p{N}])\#(NSRegularExpression.escapedPattern(for: term))(?![\p{L}\p{N}])"#,
|
||||||
|
with: term,
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripTrailingSilenceHallucination(from input: String) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.run()
|
||||||
@@ -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: ""
|
||||||
|
),
|
||||||
|
"Это новое предложение."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+16
@@ -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"
|
||||||
Executable
+38
@@ -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
|
||||||
Executable
+18
@@ -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"
|
||||||
Executable
+50
@@ -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"
|
||||||
Reference in New Issue
Block a user