Files
wisper-key/Sources/WhisperKey/AppDelegate.swift
T

379 lines
14 KiB
Swift

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