366 lines
13 KiB
Swift
366 lines
13 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()
|
|
let historyList: HistoryViewModel
|
|
let dictionaryList: DictionaryViewModel
|
|
|
|
// 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>()
|
|
|
|
convenience init() {
|
|
self.init(preview: false)
|
|
}
|
|
|
|
/// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey.
|
|
init(preview: Bool) {
|
|
let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings()
|
|
let policy = InsertionPolicy()
|
|
let knowledge = SystemWordKnowledge()
|
|
let corrector = DictionaryCorrector(knowledge: knowledge)
|
|
let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory)
|
|
let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
|
|
let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : 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.historyList = HistoryViewModel(store: history)
|
|
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
|
|
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()
|
|
if preview {
|
|
loadPreviewData()
|
|
} else {
|
|
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.permissionsMissing }
|
|
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] id in
|
|
guard let self else { return }
|
|
self.hotkey.binding = HotkeyBinding.binding(id: id) ?? .fn
|
|
self.objectWillChange.send()
|
|
}
|
|
.store(in: &cancellables)
|
|
permissions.$inputMonitoring
|
|
.dropFirst()
|
|
.removeDuplicates()
|
|
.filter { $0 }
|
|
.sink { [weak self] _ in self?.retryHotkey() }
|
|
.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.dictionaryList.reload()
|
|
self.dictionaryRevision += 1
|
|
}
|
|
history.onChange = { [weak self] in
|
|
self?.historyRevision += 1
|
|
self?.historyList.refresh()
|
|
}
|
|
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)
|
|
if state != .recording { policy.windowSession = false }
|
|
}
|
|
}
|
|
|
|
func stopWindowRecording() {
|
|
guard isRecording 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()
|
|
}
|
|
|
|
func openDictionaryFile() {
|
|
NSWorkspace.shared.open(dictionary.fileURL)
|
|
}
|
|
|
|
// MARK: preview
|
|
|
|
private func loadPreviewData() {
|
|
try? dictionary.load()
|
|
try? dictionary.apply { file in
|
|
file.append(.term("Anthropic"))
|
|
file.append(.term("Supabase"))
|
|
file.append(.correction(heard: "cloud code", wrote: "Claude Code"))
|
|
file.append(.correction(heard: "cloud", wrote: "Claude"))
|
|
}
|
|
let now = Date()
|
|
history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1,
|
|
rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning."))
|
|
history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2,
|
|
rawText: "ik wil de meeting verplaatsen naar tomorrow morning",
|
|
finalText: "Ik wil de meeting verplaatsen naar tomorrow morning."))
|
|
history.add(HistoryEntry(
|
|
date: now.addingTimeInterval(-300), duration: 4.4,
|
|
rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.",
|
|
corrections: [
|
|
AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11),
|
|
AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8),
|
|
]))
|
|
activeVariant = ModelCatalog.defaultVariant
|
|
modelStatus = .ready
|
|
lastDuration = 4.4
|
|
historyList.selection = history.entries.first?.id
|
|
}
|
|
}
|