--------- Co-authored-by: intelligence <intelligence@debian.local> Co-authored-by: jourintown <jourintown@proton.me> Reviewed-on: #2
238 lines
6.5 KiB
Swift
238 lines
6.5 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 text = try await transcriptionClient.transcribe(
|
|
recording: recording,
|
|
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)
|
|
}
|
|
}
|