Files
sst/Sources/Sst/AppModel.swift
T

306 lines
11 KiB
Swift

import AppKit
import AVFoundation
import Combine
import SstCore
import SstData
import SstWhisper
import SwiftUI
/// Decides at insertion time whether dictated text may be pasted.
/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window.
final class InsertionPolicy {
var windowSession = false
func allowed() -> Bool {
!windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier
}
}
@MainActor
final class AppModel: ObservableObject {
enum Section: String, CaseIterable, Identifiable {
case transcripts, dictionary
var id: String { rawValue }
}
enum ModelStatus: Equatable {
case loading(String)
case downloading(String, Double)
case ready
case failed(String)
}
// Services
let settings: AppSettings
let permissions = PermissionMonitor()
let levelSource = LevelSource()
let history: HistoryStore
let dictionary: DictionaryStore
let storage = ModelStorage()
// Observable state
@Published private(set) var state: DictationState = .idle
@Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant)
@Published private(set) var activeVariant: String?
@Published private(set) var installedVariants: Set<String> = []
@Published private(set) var recordingStartedAt: Date?
@Published private(set) var lastDuration: TimeInterval?
/// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh.
@Published private(set) var historyRevision = 0
@Published private(set) var dictionaryRevision = 0
@Published private(set) var searchFocusTick = 0
@Published var lastError: String?
@Published var section: Section = .transcripts
@Published var confirmingClearHistory = false
private let transcriber = SwappableTranscriber()
private let corrector: DictionaryCorrector
private let hotkey = HotkeyMonitor()
private let policy: InsertionPolicy
private let controller: DictationController
private var modelTask: Task<Void, Never>?
private var loadingVariant: String?
private var cancellables = Set<AnyCancellable>()
init() {
let settings = AppSettings()
let policy = InsertionPolicy()
let knowledge = SystemWordKnowledge()
let corrector = DictionaryCorrector(knowledge: knowledge)
let dictionary = DictionaryStore(fileURL: AppPaths.dictionary)
let history = HistoryStore(fileURL: AppPaths.history)
let levelSource = self.levelSource
let recorder = AudioRecorder()
recorder.onLevel = { levelSource.update($0) }
self.settings = settings
self.policy = policy
self.corrector = corrector
self.dictionary = dictionary
self.history = history
self.controller = DictationController(
recorder: recorder,
transcriber: transcriber,
cleaner: PassthroughCleaner(),
inserter: GatedInserter(
inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
shouldInsert: { policy.allowed() }),
corrector: corrector,
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
wire()
Task { await start() }
}
// MARK: derived
var isRecording: Bool { state == .recording }
var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn }
var modelReady: Bool { modelStatus == .ready }
/// Why recording is not possible right now, or nil when it is.
var recordingBlocker: String? {
if !permissions.allGranted { return Strings.openSystemSettings }
switch modelStatus {
case .ready: return nil
case .loading(let variant): return Strings.modelLoading(title(for: variant))
case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100))
case .failed: return Strings.modelFailed
}
}
var statusLine: String {
if let blocker = recordingBlocker, state == .idle { return blocker }
switch state {
case .idle: return Strings.holdToDictate(hotkeyBinding.displayName)
case .recording: return Strings.statusListening
case .transcribing: return Strings.statusTranscribing
case .inserting: return Strings.statusInserting
}
}
var menuIconName: String {
switch state {
case .idle:
if lastError != nil { return "exclamationmark.triangle" }
return modelReady ? "mic" : "mic.slash"
case .recording: return "mic.fill"
case .transcribing, .inserting: return "waveform"
}
}
func title(for variant: String) -> String {
ModelCatalog.all.first { $0.id == variant }?.title ?? variant
}
// MARK: lifecycle
private func wire() {
controller.onStateChange = { [weak self] state in
guard let self else { return }
self.state = state
switch state {
case .recording:
self.lastError = nil
self.recordingStartedAt = Date()
self.levelSource.reset()
case .idle:
if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) }
self.recordingStartedAt = nil
self.levelSource.reset()
default: break
}
}
controller.onError = { [weak self] in self?.lastError = $0 }
controller.onResult = { [weak self] result in
guard let self, self.settings.keepHistory else { return }
self.history.add(HistoryEntry(result: result))
}
hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) }
hotkey.binding = hotkeyBinding
settings.$hotkeyID
.sink { [weak self] _ in
guard let self else { return }
self.hotkey.binding = self.hotkeyBinding
self.objectWillChange.send()
}
.store(in: &cancellables)
settings.$useCorrection
.sink { [corrector] in corrector.setEnabled($0) }
.store(in: &cancellables)
permissions.objectWillChange
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
dictionary.onChange = { [weak self] in
guard let self else { return }
self.corrector.update(entries: self.dictionary.entries)
self.dictionaryRevision += 1
}
history.onChange = { [weak self] in self?.historyRevision += 1 }
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
MainActor.assumeIsolated { history.flush() }
}
}
private func start() async {
do { try dictionary.load() } catch { lastError = error.localizedDescription }
dictionary.startWatching()
if case .recoveredCorrupt = history.load() {
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
}
refreshInstalled()
await permissions.requestMissing()
retryHotkey()
activateModel(settings.modelVariant)
}
func retryHotkey() {
permissions.refresh()
do {
try hotkey.start()
} catch {
lastError = error.localizedDescription
}
}
// MARK: recording
private func handleHotkey(_ event: HotkeyEvent) {
guard modelReady else { return }
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
lastError = Strings.microphoneMissing
return
}
Task { await controller.handle(event) }
}
/// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted.
func toggleWindowRecording() {
if isRecording {
stopWindowRecording()
} else {
startWindowRecording()
}
}
func startWindowRecording() {
guard recordingBlocker == nil, state == .idle else { return }
policy.windowSession = true
Task { await controller.handle(.pressed) }
}
func stopWindowRecording() {
guard policy.windowSession else { return }
Task {
await controller.handle(.released)
policy.windowSession = false
}
}
// MARK: model
func refreshInstalled() {
installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) })
}
/// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays.
func activateModel(_ variant: String) {
modelTask?.cancel()
let previous = activeVariant
loadingVariant = variant
modelStatus = .loading(variant)
modelTask = Task { [weak self] in
guard let self else { return }
let candidate = WhisperKitTranscriber(model: variant, storage: storage)
candidate.onProgress = { [weak self] fraction in
Task { @MainActor in
guard let self, self.loadingVariant == variant else { return }
self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant)
}
}
do {
try await candidate.load()
guard !Task.isCancelled else { return }
transcriber.swap(to: candidate)
activeVariant = variant
settings.modelVariant = variant
modelStatus = .ready
} catch {
guard !Task.isCancelled else { return }
lastError = error.localizedDescription
modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready
}
if loadingVariant == variant { loadingVariant = nil }
refreshInstalled()
}
}
func deleteModel(_ variant: String) {
guard variant != activeVariant else { return }
do {
try storage.delete(variant)
} catch {
lastError = error.localizedDescription
}
refreshInstalled()
}
// MARK: navigation and history
func show(_ section: Section) {
self.section = section
NSApp.activate(ignoringOtherApps: true)
}
func focusSearch() {
searchFocusTick += 1
}
func clearHistory() {
history.removeAll()
history.flush()
}
}