Первый релиз WhisperKey

This commit is contained in:
2026-07-29 14:46:12 +06:00
commit 5751743277
19 changed files with 1436 additions and 0 deletions
+290
View File
@@ -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
}
}
}
+63
View File
@@ -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)
}
}
+195
View File
@@ -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
}
}
+61
View File
@@ -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() }
}
+93
View File
@@ -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()
}
}
}
+180
View File
@@ -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)
}
}
}
}
+183
View File
@@ -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)!)
}
}
+7
View File
@@ -0,0 +1,7 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()