feat:mac os
This commit is contained in:
1 parent
136bf7cfa3
commit
3400e2b465
18 files changed
+1129
No files matched your search
@@ -0,0 +1,221 @@
|
||||
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 !settings.endpoint.trimmingCharacters(in: .whitespacesAndNewlines).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,
|
||||
endpoint: settings.endpoint,
|
||||
model: settings.model,
|
||||
key: settings.apiKey,
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user