438 lines
16 KiB
Swift
438 lines
16 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import AVFoundation
|
|
|
|
@MainActor
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
private struct PendingChunk {
|
|
let url: URL
|
|
let continuesPhrase: Bool
|
|
}
|
|
|
|
private struct PendingSnapshot {
|
|
let urls: [URL]
|
|
let isFinal: Bool
|
|
let createdAt: ContinuousClock.Instant
|
|
}
|
|
|
|
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
private let recorder = AudioRecorder()
|
|
private let hotKeys = HotKeyManager()
|
|
private let settings = AppSettings.shared
|
|
private var activeMode: TranscriptionMode?
|
|
private var pendingChunks: [PendingChunk] = []
|
|
private var pendingSnapshots: [PendingSnapshot] = []
|
|
private var stableSegments: [URL] = []
|
|
private var stableTranscript = StableTranscript()
|
|
private var processingChunk = false
|
|
private var stopRequested = false
|
|
private var stitchNextChunk = false
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
configureStatusItem()
|
|
requestPermissions()
|
|
hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } }
|
|
hotKeys.onCloud = { [weak self] in Task { @MainActor in await self?.toggle(.cloud) } }
|
|
do {
|
|
try hotKeys.register()
|
|
setStatus("загружаю модель…")
|
|
} catch {
|
|
showError("Не удалось зарегистрировать F5", error.localizedDescription)
|
|
}
|
|
Task {
|
|
do {
|
|
try await LocalWhisper.preload(settings: settings)
|
|
if activeMode == nil { setStatus("готово") }
|
|
} catch {
|
|
if activeMode == nil { setStatus("ошибка модели") }
|
|
}
|
|
}
|
|
NSWorkspace.shared.notificationCenter.addObserver(
|
|
self,
|
|
selector: #selector(handleWake),
|
|
name: NSWorkspace.didWakeNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
private func configureStatusItem() {
|
|
statusItem.button?.image = NSImage(systemSymbolName: "waveform.badge.mic", accessibilityDescription: "WhisperKey")
|
|
statusItem.button?.imagePosition = .imageLeading
|
|
rebuildMenu()
|
|
}
|
|
|
|
private func rebuildMenu() {
|
|
let menu = NSMenu()
|
|
let state = NSMenuItem(title: "WhisperKey — готов", action: nil, keyEquivalent: "")
|
|
state.tag = 100
|
|
state.isEnabled = false
|
|
menu.addItem(state)
|
|
menu.addItem(.separator())
|
|
menu.addItem(item("F5 — локальная диктовка", #selector(startLocal)))
|
|
menu.addItem(item("⌥F5 — OpenAI", #selector(startCloud)))
|
|
menu.addItem(.separator())
|
|
menu.addItem(item("Настройки…", #selector(openSettings)))
|
|
menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine)))
|
|
menu.addItem(.separator())
|
|
menu.addItem(item("Выход", #selector(quit), key: "q"))
|
|
statusItem.menu = menu
|
|
}
|
|
|
|
private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem {
|
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
|
item.target = self
|
|
return item
|
|
}
|
|
|
|
private func setStatus(_ text: String) {
|
|
statusItem.button?.title = activeMode == nil ? "" : " \(text)"
|
|
statusItem.menu?.item(withTag: 100)?.title = "WhisperKey — \(text)"
|
|
}
|
|
|
|
private func requestPermissions() {
|
|
AVCaptureDevice.requestAccess(for: .audio) { _ in }
|
|
let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
|
|
_ = AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
|
|
private func toggle(_ mode: TranscriptionMode) async {
|
|
if activeMode != nil {
|
|
await stopAndTranscribe()
|
|
return
|
|
}
|
|
guard !stopRequested else { return }
|
|
do {
|
|
activeMode = mode
|
|
lastMode = mode
|
|
pendingChunks.removeAll()
|
|
pendingSnapshots.removeAll()
|
|
stableSegments.forEach { try? FileManager.default.removeItem(at: $0) }
|
|
stableSegments.removeAll()
|
|
stableTranscript = StableTranscript()
|
|
processingChunk = false
|
|
stopRequested = false
|
|
stitchNextChunk = false
|
|
try recorder.start(
|
|
chunking: mode == .local ? .stablePreviews : .committed,
|
|
onChunk: { [weak self] chunk in
|
|
Task { @MainActor in
|
|
if mode == .local {
|
|
self?.enqueueStableSegment(chunk.url)
|
|
} else {
|
|
self?.enqueueCommitted(chunk)
|
|
}
|
|
}
|
|
},
|
|
onSilence: { [weak self] in
|
|
Task { @MainActor in self?.finishLiveSession() }
|
|
}
|
|
)
|
|
setStatus(mode == .local ? "слушаю локально…" : "слушаю для OpenAI…")
|
|
DictationSounds.playStart()
|
|
} catch {
|
|
activeMode = nil
|
|
showError("Не удалось начать запись", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func stopAndTranscribe() async {
|
|
finishLiveSession()
|
|
}
|
|
|
|
private func finishLiveSession() {
|
|
guard activeMode != nil, !stopRequested else { return }
|
|
stopRequested = true
|
|
activeMode = nil
|
|
DictationSounds.playStop()
|
|
setStatus("обрабатываю…")
|
|
do {
|
|
if let final = try recorder.stop() {
|
|
if lastMode == .local {
|
|
stableSegments.append(final)
|
|
} else {
|
|
enqueueCommitted(RecordedChunk(url: final, continuesPhrase: false))
|
|
}
|
|
}
|
|
if lastMode == .local {
|
|
Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
self?.enqueueFinalStableSnapshot()
|
|
}
|
|
} else if pendingChunks.isEmpty, !processingChunk {
|
|
completeLiveSession()
|
|
}
|
|
} catch {
|
|
setStatus("ошибка")
|
|
showError("Диктовка не удалась", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func enqueueFinalStableSnapshot() {
|
|
pendingSnapshots.removeAll(where: { !$0.isFinal })
|
|
if stableSegments.isEmpty {
|
|
completeLiveSession()
|
|
} else {
|
|
enqueueStableSnapshot(
|
|
PendingSnapshot(
|
|
urls: stableSegments,
|
|
isFinal: true,
|
|
createdAt: ContinuousClock.now
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func enqueueCommitted(_ chunk: RecordedChunk) {
|
|
pendingChunks.append(PendingChunk(
|
|
url: chunk.url,
|
|
continuesPhrase: chunk.continuesPhrase
|
|
))
|
|
processNextCommittedChunk()
|
|
}
|
|
|
|
private func enqueueStableSegment(_ url: URL) {
|
|
stableSegments.append(url)
|
|
guard !stopRequested else { return }
|
|
enqueueStableSnapshot(
|
|
PendingSnapshot(
|
|
urls: Array(stableSegments.suffix(6)),
|
|
isFinal: false,
|
|
createdAt: ContinuousClock.now
|
|
)
|
|
)
|
|
}
|
|
|
|
private func enqueueStableSnapshot(_ snapshot: PendingSnapshot) {
|
|
if snapshot.isFinal {
|
|
pendingSnapshots.removeAll(where: { !$0.isFinal })
|
|
pendingSnapshots.append(snapshot)
|
|
} else if let index = pendingSnapshots.lastIndex(where: { !$0.isFinal }) {
|
|
pendingSnapshots[index] = snapshot
|
|
} else {
|
|
pendingSnapshots.append(snapshot)
|
|
}
|
|
processNextStableSnapshot()
|
|
}
|
|
|
|
private func processNextCommittedChunk() {
|
|
guard !processingChunk, !pendingChunks.isEmpty else { return }
|
|
processingChunk = true
|
|
let chunk = pendingChunks.removeFirst()
|
|
let recording = chunk.url
|
|
let mode = activeMode ?? (stopRequested ? lastMode : nil)
|
|
guard let mode else {
|
|
processingChunk = false
|
|
try? FileManager.default.removeItem(at: recording)
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
let wav = try AudioConverter.toWhisperWAV(recording)
|
|
saveDiagnosticRecording(wav)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: recording)
|
|
try? FileManager.default.removeItem(at: wav)
|
|
}
|
|
let raw: String
|
|
switch mode {
|
|
case .local: raw = try await LocalWhisper.transcribe(wav: wav, settings: settings)
|
|
case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings)
|
|
}
|
|
let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary)
|
|
if !cleaned.isEmpty {
|
|
let joined = TranscriptJoiner.prepare(
|
|
cleaned,
|
|
continuesPrevious: stitchNextChunk,
|
|
continuesNext: chunk.continuesPhrase,
|
|
glossary: settings.glossary
|
|
)
|
|
if !joined.isEmpty { try TextInserter.insert(joined + " ") }
|
|
stitchNextChunk = chunk.continuesPhrase
|
|
}
|
|
} catch {
|
|
showError("Фрагмент диктовки не распознан", error.localizedDescription)
|
|
}
|
|
processingChunk = false
|
|
if pendingChunks.isEmpty {
|
|
if stopRequested { completeLiveSession() }
|
|
} else {
|
|
processNextCommittedChunk()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processNextStableSnapshot() {
|
|
guard !processingChunk, !pendingSnapshots.isEmpty else { return }
|
|
processingChunk = true
|
|
let snapshot = pendingSnapshots.removeFirst()
|
|
let mode = activeMode ?? (stopRequested ? lastMode : nil)
|
|
guard let mode else {
|
|
processingChunk = false
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let processingStartedAt = ContinuousClock.now
|
|
let wav = try AudioConverter.toWhisperWAV(snapshot.urls)
|
|
if snapshot.isFinal { saveDiagnosticRecording(wav) }
|
|
defer { try? FileManager.default.removeItem(at: wav) }
|
|
|
|
let raw: String
|
|
switch mode {
|
|
case .local:
|
|
raw = try await LocalWhisper.transcribe(wav: wav, settings: settings)
|
|
case .cloud:
|
|
raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings)
|
|
}
|
|
let cleaned = TextCleaner.clean(
|
|
raw,
|
|
removeFillers: settings.removeFillers,
|
|
glossary: settings.glossary
|
|
)
|
|
if let update = stableTranscript.ingest(cleaned, final: snapshot.isFinal) {
|
|
try insertStableUpdate(update)
|
|
NSLog(
|
|
"WhisperKey stable commit: final=%d chars=%ld total=%@ processing=%@",
|
|
snapshot.isFinal ? 1 : 0,
|
|
(update.text as NSString).length,
|
|
String(describing: snapshot.createdAt.duration(to: .now)),
|
|
String(describing: processingStartedAt.duration(to: .now))
|
|
)
|
|
}
|
|
} catch {
|
|
if snapshot.isFinal,
|
|
let update = stableTranscript.ingest("", final: true) {
|
|
try? insertStableUpdate(update)
|
|
}
|
|
if snapshot.isFinal {
|
|
showError("Финальный фрагмент диктовки не распознан", error.localizedDescription)
|
|
} else {
|
|
NSLog("WhisperKey preview failed: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
processingChunk = false
|
|
if pendingSnapshots.isEmpty {
|
|
if stopRequested, snapshot.isFinal { completeLiveSession() }
|
|
} else {
|
|
processNextStableSnapshot()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func insertStableUpdate(_ update: StableTranscriptUpdate) throws {
|
|
let text = TranscriptJoiner.prepare(
|
|
update.text,
|
|
continuesPrevious: update.continuesPrevious,
|
|
continuesNext: false,
|
|
glossary: settings.glossary
|
|
)
|
|
if !text.isEmpty {
|
|
try TextInserter.insert(text + " ")
|
|
}
|
|
}
|
|
|
|
private func saveDiagnosticRecording(_ wav: URL) {
|
|
let directory = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
)[0].appendingPathComponent("WhisperKey", isDirectory: true)
|
|
let destination = directory.appendingPathComponent("last-dictation.wav")
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try? FileManager.default.removeItem(at: destination)
|
|
try? FileManager.default.copyItem(at: wav, to: destination)
|
|
}
|
|
|
|
private var lastMode: TranscriptionMode?
|
|
|
|
private func completeLiveSession() {
|
|
processingChunk = false
|
|
stopRequested = false
|
|
lastMode = nil
|
|
stitchNextChunk = false
|
|
pendingSnapshots.removeAll()
|
|
stableSegments.forEach { try? FileManager.default.removeItem(at: $0) }
|
|
stableSegments.removeAll()
|
|
stableTranscript = StableTranscript()
|
|
setStatus("готово")
|
|
}
|
|
|
|
@objc private func startLocal() { Task { await toggle(.local) } }
|
|
@objc private func startCloud() { Task { await toggle(.cloud) } }
|
|
@objc private func openSettings() { SettingsWindowController.shared.show() }
|
|
@objc private func checkLocalEngine() {
|
|
do {
|
|
try LocalWhisper.validate(settings: settings)
|
|
showInfo("Локальный Whisper готов", settings.localModelPath)
|
|
} catch {
|
|
showError("Локальный движок не готов", error.localizedDescription)
|
|
}
|
|
}
|
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
|
|
@objc private func handleWake() {
|
|
do {
|
|
try hotKeys.register()
|
|
setStatus("готово")
|
|
} catch {
|
|
setStatus("F5 недоступна")
|
|
}
|
|
}
|
|
|
|
private func showError(_ title: String, _ message: String) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.runModal()
|
|
}
|
|
|
|
private func showInfo(_ title: String, _ message: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
enum TranscriptionMode: Sendable, Equatable {
|
|
case local
|
|
case cloud
|
|
}
|
|
|
|
private enum DictationSounds {
|
|
private static let directory = "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/system"
|
|
|
|
static func playStart() {
|
|
play("begin_record.caf")
|
|
}
|
|
|
|
static func playStop() {
|
|
play("end_record.caf")
|
|
}
|
|
|
|
private static func play(_ filename: String) {
|
|
let url = URL(fileURLWithPath: directory).appendingPathComponent(filename)
|
|
NSSound(contentsOf: url, byReference: true)?.play()
|
|
}
|
|
}
|
|
|
|
enum WhisperKeyError: LocalizedError {
|
|
case emptyTranscript
|
|
case missingDependency(String)
|
|
case processFailed(String)
|
|
case missingAPIKey
|
|
case badResponse(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .emptyTranscript: return "Речь не распознана."
|
|
case .missingDependency(let value): return "Не найдено: \(value)"
|
|
case .processFailed(let value): return value
|
|
case .missingAPIKey: return "Добавьте OpenAI API key в настройках WhisperKey."
|
|
case .badResponse(let value): return value
|
|
}
|
|
}
|
|
}
|