diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..e11dfec --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,2 @@ +.build/ +dist/ diff --git a/macos/Info.plist b/macos/Info.plist new file mode 100644 index 0000000..4d41612 --- /dev/null +++ b/macos/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Transui + CFBundleIdentifier + io.github.transui.macos + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Transui + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 15.0 + LSUIElement + + NSHighResolutionCapable + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSMicrophoneUsageDescription + Transui records speech to transcribe it. + + diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 0000000..34bff6e --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TransuiMac", + platforms: [.macOS("15.0")], + products: [ + .executable(name: "Transui", targets: ["Transui"]) + ], + targets: [ + .executableTarget(name: "Transui"), + .testTarget(name: "TransuiTests", dependencies: ["Transui"]), + ] +) diff --git a/macos/Sources/Transui/AppModel.swift b/macos/Sources/Transui/AppModel.swift new file mode 100644 index 0000000..75f2292 --- /dev/null +++ b/macos/Sources/Transui/AppModel.swift @@ -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() + 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) + } +} diff --git a/macos/Sources/Transui/AppSettings.swift b/macos/Sources/Transui/AppSettings.swift new file mode 100644 index 0000000..d2a615b --- /dev/null +++ b/macos/Sources/Transui/AppSettings.swift @@ -0,0 +1,66 @@ +import Combine +import Foundation + +enum TranscriptDestination: String, CaseIterable, Identifiable { + case cursor + case clipboard + + var id: Self { self } + + var label: String { + switch self { + case .cursor: "Type at cursor" + case .clipboard: "Copy to clipboard" + } + } +} + +@MainActor +final class AppSettings: ObservableObject { + private enum Key { + static let endpoint = "endpoint" + static let model = "model" + static let prompt = "prompt" + static let destination = "destination" + static let shortcut = "shortcut" + } + + private let defaults: UserDefaults + + @Published var endpoint: String { didSet { defaults.set(endpoint, forKey: Key.endpoint) } } + @Published var model: String { didSet { defaults.set(model, forKey: Key.model) } } + @Published var prompt: String { didSet { defaults.set(prompt, forKey: Key.prompt) } } + @Published var destination: TranscriptDestination { + didSet { defaults.set(destination.rawValue, forKey: Key.destination) } + } + @Published var shortcut: HotkeyShortcut { + didSet { + if let data = try? JSONEncoder().encode(shortcut) { + defaults.set(data, forKey: Key.shortcut) + } + } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + endpoint = defaults.string(forKey: Key.endpoint) ?? "" + model = defaults.string(forKey: Key.model) ?? "" + prompt = defaults.string(forKey: Key.prompt) ?? "" + destination = + TranscriptDestination( + rawValue: defaults.string(forKey: Key.destination) ?? "" + ) ?? .cursor + if let data = defaults.data(forKey: Key.shortcut), + let saved = try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + { + shortcut = saved + } else { + shortcut = .defaultShortcut + } + } + + var apiKey: String { + get { KeychainStore.read() } + set { KeychainStore.write(newValue) } + } +} diff --git a/macos/Sources/Transui/AudioRecorder.swift b/macos/Sources/Transui/AudioRecorder.swift new file mode 100644 index 0000000..b62757d --- /dev/null +++ b/macos/Sources/Transui/AudioRecorder.swift @@ -0,0 +1,86 @@ +import AVFoundation +import Foundation + +enum RecordingError: LocalizedError { + case microphoneDenied + case couldNotStart + + var errorDescription: String? { + switch self { + case .microphoneDenied: "Microphone access is off. Enable it in System Settings." + case .couldNotStart: "The microphone could not start recording." + } + } +} + +@MainActor +final class AudioRecorder: NSObject, AVAudioRecorderDelegate { + private var recorder: AVAudioRecorder? + private var meterTimer: Timer? + private(set) var fileURL: URL? + var onLevel: ((Double) -> Void)? + + func start() async throws { + guard await Self.requestPermission() else { throw RecordingError.microphoneDenied } + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("transui-\(UUID().uuidString).wav") + self.fileURL = fileURL + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatLinearPCM), + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + ] + let recorder = try AVAudioRecorder(url: fileURL, settings: settings) + recorder.delegate = self + recorder.isMeteringEnabled = true + guard recorder.prepareToRecord(), recorder.record() else { + throw RecordingError.couldNotStart + } + self.recorder = recorder + meterTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + Task { @MainActor in self?.updateLevel() } + } + } + + func stop() -> URL? { + meterTimer?.invalidate() + meterTimer = nil + recorder?.stop() + recorder = nil + onLevel?(0) + let url = fileURL + fileURL = nil + return url + } + + func cancel() { + let url = stop() + if let url { try? FileManager.default.removeItem(at: url) } + fileURL = nil + } + + private func updateLevel() { + guard let recorder else { return } + recorder.updateMeters() + let decibels = Double(recorder.averagePower(forChannel: 0)) + let normalized = max(0, min(1, pow(10, decibels / 32))) + onLevel?(normalized) + } + + private static func requestPermission() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: true + case .denied, .restricted: false + case .notDetermined: + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { allowed in + continuation.resume(returning: allowed) + } + } + @unknown default: false + } + } +} diff --git a/macos/Sources/Transui/HotkeyManager.swift b/macos/Sources/Transui/HotkeyManager.swift new file mode 100644 index 0000000..1b4f72c --- /dev/null +++ b/macos/Sources/Transui/HotkeyManager.swift @@ -0,0 +1,70 @@ +import Carbon.HIToolbox +import Foundation + +final class HotkeyManager { + private var hotkey: EventHotKeyRef? + private var handler: EventHandlerRef? + private var action: (() -> Void)? + + init() { + var type = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + InstallEventHandler( + GetApplicationEventTarget(), + { _, event, context in + guard let event, let context else { return noErr } + var identifier = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &identifier + ) + guard status == noErr, identifier.id == 1 else { return status } + let manager = Unmanaged.fromOpaque(context).takeUnretainedValue() + DispatchQueue.main.async { manager.action?() } + return noErr + }, + 1, + &type, + Unmanaged.passUnretained(self).toOpaque(), + &handler + ) + } + + deinit { + unregister() + if let handler { RemoveEventHandler(handler) } + } + + @discardableResult + func register(_ shortcut: HotkeyShortcut, action: @escaping () -> Void) -> Bool { + let identifier = EventHotKeyID(signature: Self.signature, id: 1) + var candidate: EventHotKeyRef? + let status = RegisterEventHotKey( + shortcut.keyCode, + shortcut.modifiers, + identifier, + GetApplicationEventTarget(), + 0, + &candidate + ) + guard status == noErr, let candidate else { return false } + unregister() + hotkey = candidate + self.action = action + return true + } + + private func unregister() { + if let hotkey { UnregisterEventHotKey(hotkey) } + hotkey = nil + } + + private static let signature: OSType = 0x5452_5549 // TRUI +} diff --git a/macos/Sources/Transui/HotkeyRecorder.swift b/macos/Sources/Transui/HotkeyRecorder.swift new file mode 100644 index 0000000..1103618 --- /dev/null +++ b/macos/Sources/Transui/HotkeyRecorder.swift @@ -0,0 +1,59 @@ +import AppKit +import SwiftUI + +struct HotkeyRecorder: NSViewRepresentable { + @Binding var shortcut: HotkeyShortcut + + func makeNSView(context: Context) -> HotkeyCaptureView { + let view = HotkeyCaptureView() + view.onShortcut = { shortcut = $0 } + view.stringValue = shortcut.displayName + return view + } + + func updateNSView(_ view: HotkeyCaptureView, context: Context) { + view.onShortcut = { shortcut = $0 } + if view.window?.firstResponder !== view { + view.stringValue = shortcut.displayName + } + } +} + +final class HotkeyCaptureView: NSTextField { + var onShortcut: ((HotkeyShortcut) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isEditable = false + isSelectable = false + isBezeled = true + bezelStyle = .roundedBezel + alignment = .center + font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .medium) + toolTip = "Click, then press a shortcut" + } + + required init?(coder: NSCoder) { nil } + + override var acceptsFirstResponder: Bool { true } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + stringValue = "Type shortcut" + } + + override func keyDown(with event: NSEvent) { + guard let shortcut = HotkeyShortcut(event: event) else { + NSSound.beep() + return + } + stringValue = shortcut.displayName + onShortcut?(shortcut) + window?.makeFirstResponder(nil) + } + + override func resignFirstResponder() -> Bool { + needsDisplay = true + return super.resignFirstResponder() + } +} diff --git a/macos/Sources/Transui/HotkeyShortcut.swift b/macos/Sources/Transui/HotkeyShortcut.swift new file mode 100644 index 0000000..a5d6a26 --- /dev/null +++ b/macos/Sources/Transui/HotkeyShortcut.swift @@ -0,0 +1,67 @@ +import AppKit +import Carbon.HIToolbox + +struct HotkeyShortcut: Codable, Equatable { + let keyCode: UInt32 + let modifiers: UInt32 + + static let defaultShortcut = HotkeyShortcut( + keyCode: UInt32(kVK_ANSI_S), + modifiers: UInt32(cmdKey | optionKey) + ) + + var displayName: String { + var result = "" + if modifiers & UInt32(controlKey) != 0 { result += "⌃" } + if modifiers & UInt32(optionKey) != 0 { result += "⌥" } + if modifiers & UInt32(shiftKey) != 0 { result += "⇧" } + if modifiers & UInt32(cmdKey) != 0 { result += "⌘" } + result += Self.keyNames[keyCode] ?? "Key \(keyCode)" + return result + } + + init(keyCode: UInt32, modifiers: UInt32) { + self.keyCode = keyCode + self.modifiers = modifiers + } + + init?(event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + var carbonModifiers: UInt32 = 0 + if flags.contains(.command) { carbonModifiers |= UInt32(cmdKey) } + if flags.contains(.option) { carbonModifiers |= UInt32(optionKey) } + if flags.contains(.control) { carbonModifiers |= UInt32(controlKey) } + if flags.contains(.shift) { carbonModifiers |= UInt32(shiftKey) } + guard carbonModifiers != 0 else { return nil } + self.init(keyCode: UInt32(event.keyCode), modifiers: carbonModifiers) + } + + private static let keyNames: [UInt32: String] = [ + UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", + UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D", + UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", + UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", + UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J", + UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L", + UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", + UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P", + UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", + UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", + UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V", + UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X", + UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z", + UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1", + UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3", + UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", + UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7", + UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9", + UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", + UInt32(kVK_Tab): "Tab", UInt32(kVK_Escape): "Esc", + UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", + UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4", + UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", + UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8", + UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", + UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12", + ] +} diff --git a/macos/Sources/Transui/KeychainStore.swift b/macos/Sources/Transui/KeychainStore.swift new file mode 100644 index 0000000..0092b73 --- /dev/null +++ b/macos/Sources/Transui/KeychainStore.swift @@ -0,0 +1,41 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "io.github.transui.macos" + private static let account = "remote-api-key" + + static func read() -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + + static func write(_ value: String) { + let identity: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if value.isEmpty { + SecItemDelete(identity as CFDictionary) + return + } + let data = Data(value.utf8) + let attributes = [kSecValueData as String: data] + if SecItemUpdate(identity as CFDictionary, attributes as CFDictionary) == errSecItemNotFound { + var item = identity + item[kSecValueData as String] = data + SecItemAdd(item as CFDictionary, nil) + } + } +} diff --git a/macos/Sources/Transui/OverlayPanel.swift b/macos/Sources/Transui/OverlayPanel.swift new file mode 100644 index 0000000..e712d40 --- /dev/null +++ b/macos/Sources/Transui/OverlayPanel.swift @@ -0,0 +1,133 @@ +import AppKit +import SwiftUI + +@MainActor +final class OverlayPanelController { + private let panel: NSPanel + + init(model: AppModel) { + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 304, height: 82), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: RecordingOverlay(model: model)) + } + + func show() { + positionPanel() + panel.orderFrontRegardless() + } + + func hide() { + panel.orderOut(nil) + } + + private func positionPanel() { + let mouse = NSEvent.mouseLocation + let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main + guard let frame = screen?.visibleFrame else { return } + let x = frame.midX - panel.frame.width / 2 + let y = frame.minY + 42 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} + +private struct RecordingOverlay: View { + @ObservedObject var model: AppModel + private let teal = Color(red: 0.039, green: 0.624, blue: 0.604) + + var body: some View { + HStack(spacing: 14) { + signal + .frame(width: 46, height: 34) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Text(detail) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + if model.isRecording { + Button(action: model.toggleRecording) { + Image(systemName: "stop.fill") + .font(.system(size: 11, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.borderedProminent) + .tint(teal) + .clipShape(Circle()) + .help("Stop and transcribe") + } + } + .padding(.horizontal, 17) + .frame(width: 304, height: 82) + .background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 19, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 19, style: .continuous) + .strokeBorder(.white.opacity(0.13)) + } + } + + @ViewBuilder + private var signal: some View { + switch model.state { + case .recording: + HStack(alignment: .center, spacing: 3) { + ForEach(0..<5, id: \.self) { index in + Capsule() + .fill(teal) + .frame(width: 4, height: barHeight(index)) + } + } + case .preparing, .transcribing: + ProgressView().controlSize(.small).tint(teal) + case .success: + Image(systemName: "checkmark.circle.fill").font(.title2).foregroundStyle(teal) + case .failure: + Image(systemName: "exclamationmark.triangle.fill").font(.title3).foregroundStyle(.red) + case .idle: + Image(systemName: "waveform").foregroundStyle(teal) + } + } + + private var title: String { + switch model.state { + case .preparing: "Starting microphone" + case .recording: "Recording" + case .transcribing: "Transcribing" + case .success(let message), .failure(let message): message + case .idle: "Ready" + } + } + + private var detail: String { + switch model.state { + case .recording: Self.duration(model.elapsed) + case .preparing: "Waiting for permission" + case .transcribing: "Sending audio" + case .failure: "Open Transui settings to fix this" + case .success: "Done" + case .idle: model.settings.shortcut.displayName + } + } + + private func barHeight(_ index: Int) -> CGFloat { + let shape = [0.55, 0.82, 1.0, 0.72, 0.45][index] + return 7 + 24 * max(0.08, model.level * shape) + } + + private static func duration(_ interval: TimeInterval) -> String { + let seconds = Int(interval) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/macos/Sources/Transui/SettingsView.swift b/macos/Sources/Transui/SettingsView.swift new file mode 100644 index 0000000..af1faf0 --- /dev/null +++ b/macos/Sources/Transui/SettingsView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var model: AppModel + @ObservedObject private var settings: AppSettings + @State private var apiKey = "" + + init(model: AppModel) { + self.model = model + settings = model.settings + } + + var body: some View { + Form { + Section("Transcription") { + TextField("Endpoint", text: $settings.endpoint, prompt: Text("https://api.example.com")) + TextField("Model", text: $settings.model) + SecureField("API key", text: $apiKey) + .onChange(of: apiKey) { _, value in settings.apiKey = value } + TextField("Prompt", text: $settings.prompt, axis: .vertical) + .lineLimit(2...4) + Picker("Output", selection: $settings.destination) { + ForEach(TranscriptDestination.allCases) { destination in + Text(destination.label).tag(destination) + } + } + } + + Section("Controls") { + LabeledContent("Record shortcut") { + HotkeyRecorder(shortcut: $settings.shortcut) + .frame(width: 132, height: 24) + } + if let shortcutError = model.shortcutError { + Text(shortcutError) + .font(.caption) + .foregroundStyle(.red) + } + Toggle( + "Start at login", + isOn: Binding( + get: { model.startsAtLogin }, + set: model.setStartAtLogin + ) + ) + } + + Section("Permissions") { + LabeledContent("Microphone") { + Button("Open settings", action: model.openMicrophoneSettings) + } + LabeledContent("Type at cursor") { + Button("Open Accessibility", action: model.openAccessibilitySettings) + } + } + } + .formStyle(.grouped) + .frame(width: 500, height: 470) + .onAppear { apiKey = settings.apiKey } + } +} diff --git a/macos/Sources/Transui/TranscriptCleaner.swift b/macos/Sources/Transui/TranscriptCleaner.swift new file mode 100644 index 0000000..7e68a52 --- /dev/null +++ b/macos/Sources/Transui/TranscriptCleaner.swift @@ -0,0 +1,20 @@ +import Foundation + +enum TranscriptCleaner { + static func clean(_ text: String) -> String { + var prefix = text.startIndex + if prefix < text.endIndex, text[prefix] == "\u{FEFF}" { + prefix = text.index(after: prefix) + } + while prefix < text.endIndex, text[prefix].isWhitespace { + prefix = text.index(after: prefix) + } + guard text[prefix...].hasPrefix("language ") else { return text } + + let markers = ["", ""] + let marker = markers.compactMap { text.range(of: $0, range: prefix.. OutputResult { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + guard destination == .cursor else { return .copied } + + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] + guard AXIsProcessTrustedWithOptions(options as CFDictionary) else { + return .copiedBecauseAccessibilityIsOff + } + guard let source = CGEventSource(stateID: .hidSystemState), + let keyDown = CGEvent( + keyboardEventSource: source, + virtualKey: CGKeyCode(kVK_ANSI_V), + keyDown: true + ), + let keyUp = CGEvent( + keyboardEventSource: source, + virtualKey: CGKeyCode(kVK_ANSI_V), + keyDown: false + ) + else { + return .copiedBecauseAccessibilityIsOff + } + keyDown.flags = .maskCommand + keyUp.flags = .maskCommand + keyDown.post(tap: .cghidEventTap) + keyUp.post(tap: .cghidEventTap) + return .typed + } +} diff --git a/macos/Sources/Transui/TranscriptionClient.swift b/macos/Sources/Transui/TranscriptionClient.swift new file mode 100644 index 0000000..db13330 --- /dev/null +++ b/macos/Sources/Transui/TranscriptionClient.swift @@ -0,0 +1,110 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +enum TranscriptionError: LocalizedError { + case invalidEndpoint + case invalidResponse + case http(Int) + + var errorDescription: String? { + switch self { + case .invalidEndpoint: "The transcription endpoint is invalid." + case .invalidResponse: "The server response did not contain a transcript." + case .http(let status): "The server returned HTTP \(status)." + } + } +} + +struct TranscriptionClient { + static func endpointURL(from value: String) -> URL? { + var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines) + while endpoint.hasSuffix("/") { endpoint.removeLast() } + if !endpoint.contains("/v1/audio/transcriptions") { + endpoint += "/v1/audio/transcriptions" + } + guard let url = URL(string: endpoint), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.host != nil + else { return nil } + return url + } + + func transcribe( + recording: URL, + endpoint: String, + model: String, + key: String, + prompt: String + ) async throws -> String { + guard let url = Self.endpointURL(from: endpoint) else { + throw TranscriptionError.invalidEndpoint + } + + let boundary = "transui-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 300 + request.setValue( + "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if !key.isEmpty { + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + } + request.httpBody = try multipartBody( + boundary: boundary, + recording: recording, + model: model, + prompt: prompt + ) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw TranscriptionError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw TranscriptionError.http(http.statusCode) + } + guard data.count <= 1_048_576 else { + throw TranscriptionError.invalidResponse + } + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = object["text"] as? String + else { + throw TranscriptionError.invalidResponse + } + return TranscriptCleaner.clean(text) + } + + private func multipartBody( + boundary: String, + recording: URL, + model: String, + prompt: String + ) throws -> Data { + var data = Data() + func field(_ name: String, _ value: String) { + data.append("--\(boundary)\r\n") + data.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + data.append("\(value)\r\n") + } + field("response_format", "json") + if !model.isEmpty { field("model", model) } + if !prompt.isEmpty { field("prompt", prompt) } + data.append("--\(boundary)\r\n") + data.append("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n") + data.append("Content-Type: audio/wav\r\n\r\n") + data.append(try Data(contentsOf: recording)) + data.append("\r\n--\(boundary)--\r\n") + return data + } +} + +extension Data { + fileprivate mutating func append(_ string: String) { + append(contentsOf: string.utf8) + } +} diff --git a/macos/Sources/Transui/TransuiApp.swift b/macos/Sources/Transui/TransuiApp.swift new file mode 100644 index 0000000..f6c1d90 --- /dev/null +++ b/macos/Sources/Transui/TransuiApp.swift @@ -0,0 +1,44 @@ +import AppKit +import SwiftUI + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + } +} + +@main +struct TransuiApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = AppModel() + + var body: some Scene { + MenuBarExtra { + Button(model.isRecording ? "Stop and transcribe" : "Start recording") { + model.toggleRecording() + } + .keyboardShortcut("r") + .disabled(model.state == .transcribing) + + if model.isRecording { + Button("Cancel recording", role: .destructive) { + model.cancelRecording() + } + } + + Divider() + SettingsLink { Text("Settings…") } + Divider() + Button("Quit Transui") { NSApp.terminate(nil) } + .keyboardShortcut("q") + } label: { + Image(systemName: model.isRecording ? "waveform.circle.fill" : "waveform") + .accessibilityLabel(model.isRecording ? "Transui recording" : "Transui") + } + + Settings { + SettingsView(model: model) + } + .windowResizability(.contentSize) + } +} diff --git a/macos/Tests/TransuiTests/TranscriptCleanerTests.swift b/macos/Tests/TransuiTests/TranscriptCleanerTests.swift new file mode 100644 index 0000000..e8e5a81 --- /dev/null +++ b/macos/Tests/TransuiTests/TranscriptCleanerTests.swift @@ -0,0 +1,30 @@ +import XCTest + +@testable import Transui + +final class TranscriptCleanerTests: XCTestCase { + func testRemovesAsTextPrefix() { + XCTAssertEqual( + TranscriptCleaner.clean("language EnglishHello there"), + "Hello there" + ) + } + + func testRemovesAsrTextPrefix() { + XCTAssertEqual( + TranscriptCleaner.clean("language zh 你好"), + "你好" + ) + } + + func testRemovesPrefixAfterWhitespaceAndBom() { + XCTAssertEqual( + TranscriptCleaner.clean("\u{FEFF} \n\tlanguage EnglishHello"), + "Hello" + ) + } + + func testPreservesOrdinaryTranscript() { + XCTAssertEqual(TranscriptCleaner.clean("language is useful"), "language is useful") + } +} diff --git a/macos/Tests/TransuiTests/TranscriptionClientTests.swift b/macos/Tests/TransuiTests/TranscriptionClientTests.swift new file mode 100644 index 0000000..8d20ccf --- /dev/null +++ b/macos/Tests/TransuiTests/TranscriptionClientTests.swift @@ -0,0 +1,26 @@ +import XCTest + +@testable import Transui + +final class TranscriptionClientTests: XCTestCase { + func testAddsTranscriptionPath() { + XCTAssertEqual( + TranscriptionClient.endpointURL(from: "https://example.com/")?.absoluteString, + "https://example.com/v1/audio/transcriptions" + ) + } + + func testKeepsFullTranscriptionPath() { + XCTAssertEqual( + TranscriptionClient.endpointURL( + from: "https://example.com/v1/audio/transcriptions" + )?.absoluteString, + "https://example.com/v1/audio/transcriptions" + ) + } + + func testRejectsInvalidEndpoint() { + XCTAssertNil(TranscriptionClient.endpointURL(from: "")) + XCTAssertNil(TranscriptionClient.endpointURL(from: "example.com")) + } +}