Files
2026-08-29 23:11:50 +02:00

242 lines
6.7 KiB
Swift

import AppKit
import Combine
import ServiceManagement
enum RecorderState: Equatable {
case idle
case preparing
case recording
case transcribing
case success(String)
case failure(String)
}
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var state: RecorderState = .idle
@Published private(set) var level = 0.0
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var startsAtLogin = SMAppService.mainApp.status == .enabled
@Published private(set) var shortcutError: String?
let settings = AppSettings()
private let audioRecorder = AudioRecorder()
private let transcriptionClient = TranscriptionClient()
private let hotkeyManager = HotkeyManager()
private lazy var overlay = OverlayPanelController(model: self)
private var elapsedTimer: Timer?
private var startedAt: Date?
private var cancellables = Set<AnyCancellable>()
private var registeredShortcut: HotkeyShortcut?
private var restoringShortcut = false
private var noticeID = UUID()
private var recordingRequestID: UUID?
init() {
audioRecorder.onLevel = { [weak self] level in self?.level = level }
registerHotkey(settings.shortcut)
settings.$shortcut
.dropFirst()
.sink { [weak self] shortcut in self?.registerHotkey(shortcut) }
.store(in: &cancellables)
}
var isRecording: Bool { state == .recording || state == .preparing }
var isWorking: Bool { isRecording || state == .transcribing }
func toggleRecording() {
switch state {
case .recording:
stopAndTranscribe()
case .preparing:
cancelRecording()
case .idle, .success, .failure:
Task { await startRecording() }
case .transcribing:
break
}
}
func cancelRecording() {
guard isRecording else { return }
elapsedTimer?.invalidate()
elapsedTimer = nil
recordingRequestID = nil
audioRecorder.cancel()
state = .idle
overlay.hide()
}
func setStartAtLogin(_ enabled: Bool) {
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
let status = SMAppService.mainApp.status
startsAtLogin = status == .enabled
if enabled, status == .requiresApproval {
showFailure("Allow Transui in Login Items.")
SMAppService.openSystemSettingsLoginItems()
}
} catch {
startsAtLogin = SMAppService.mainApp.status == .enabled
if isWorking {
NSSound.beep()
} else {
showFailure(error.localizedDescription)
}
}
}
func openMicrophoneSettings() {
openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
}
func openAccessibilitySettings() {
openSystemSettings(
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
}
private func startRecording() async {
guard !transcriptionPlan.endpoints.isEmpty else {
showFailure("Set a transcription endpoint in Settings.")
return
}
state = .preparing
overlay.show()
let requestID = UUID()
recordingRequestID = requestID
do {
try await audioRecorder.start()
guard recordingRequestID == requestID else {
audioRecorder.cancel()
return
}
elapsed = 0
startedAt = Date()
elapsedTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = Date().timeIntervalSince(startedAt)
if self.elapsed >= 300 {
self.stopAndTranscribe()
}
}
}
state = .recording
} catch {
recordingRequestID = nil
audioRecorder.cancel()
showFailure(error.localizedDescription)
}
}
private func stopAndTranscribe() {
guard let recording = audioRecorder.stop() else {
showFailure("No recording was captured.")
return
}
recordingRequestID = nil
elapsedTimer?.invalidate()
elapsedTimer = nil
state = .transcribing
Task {
defer { try? FileManager.default.removeItem(at: recording) }
do {
let encoded = try await Task.detached(priority: .userInitiated) {
try MP3Encoder.encode(wav: recording)
}.value
defer { try? FileManager.default.removeItem(at: encoded) }
let text = try await transcriptionClient.transcribe(
recording: encoded,
plan: transcriptionPlan,
prompt: settings.prompt
)
let result = TranscriptOutput.deliver(text, to: settings.destination)
switch result {
case .typed: showSuccess("Typed at cursor")
case .copied: showSuccess("Copied to clipboard")
case .copiedBecauseAccessibilityIsOff:
showSuccess("Copied. Accessibility is off")
}
} catch {
showFailure(error.localizedDescription)
}
}
}
private func registerHotkey(_ shortcut: HotkeyShortcut) {
if restoringShortcut {
restoringShortcut = false
return
}
if hotkeyManager.register(shortcut, action: { [weak self] in self?.toggleRecording() }) {
registeredShortcut = shortcut
shortcutError = nil
} else {
shortcutError = "That shortcut is already in use."
if let registeredShortcut {
restoringShortcut = true
settings.shortcut = registeredShortcut
}
}
}
private var transcriptionPlan: TranscriptionPlan {
TranscriptionPlan(
hosted: TranscriptionEndpoint(
label: "Hosted",
endpoint: settings.hostedEndpoint,
model: settings.hostedModel,
key: settings.hostedKey
),
local: TranscriptionEndpoint(
label: "Local",
endpoint: settings.localEndpoint,
model: settings.localModel,
key: settings.localKey
),
hostedFirst: settings.hostedFirst
)
}
private func showSuccess(_ message: String) {
state = .success(message)
overlay.show()
hideOverlayAfterDelay()
}
private func showFailure(_ message: String) {
elapsedTimer?.invalidate()
elapsedTimer = nil
state = .failure(message)
overlay.show()
hideOverlayAfterDelay(seconds: 3.5)
}
private func hideOverlayAfterDelay(seconds: Double = 1.6) {
let id = UUID()
noticeID = id
Task {
try? await Task.sleep(for: .seconds(seconds))
guard noticeID == id else { return }
guard case .success = state else {
if case .failure = state {
state = .idle
overlay.hide()
}
return
}
state = .idle
overlay.hide()
}
}
private func openSystemSettings(_ value: String) {
guard let url = URL(string: value) else { return }
NSWorkspace.shared.open(url)
}
}