diff --git a/AGENTS.md b/AGENTS.md index 030f64c..3b7a8f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,15 @@ # Transui design - Linux is a GTK3 desktop recorder. It supports hosted transcription with an optional local fallback and can type at the cursor or copy to the clipboard. +- macOS is a SwiftUI menu bar recorder targeting macOS 15. It supports the same hosted-first, local-fallback behavior as Linux, a global recording hotkey, launch at login, cursor typing, and clipboard output. When hosted-first is off, use local only. Its compact floating recorder uses teal level bars for recording feedback. - Android is remote-only. It provides an Android `RecognitionService`, a voice-only input method, and a floating recognition activity. Do not add a local llama.cpp path to Android. -- Both clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …` or `language …` prefix. +- All clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …` or `language …` prefix. +- Generate the macOS Xcode project from `macos/project.yml` with XcodeGen. Treat the spec as the source of truth and leave `macos/Transui.xcodeproj` untracked. - The Android voice UI is an opaque, compact system surface. Teal is the recording signal. `PushToTalkButton` draws the Apps and Stop Circle glyphs with Canvas because the target device drops `ImageButton` image layers. Keep the glyphs drawn above the button background. ## llama.cpp local endpoint -The Linux local fallback begins with this multipart request. `response_format=json` +The Linux and macOS local fallback uses this multipart request. `response_format=json` is required; `prompt` is optional and may request transcription or translation. ```sh diff --git a/README.md b/README.md index 850528e..3152848 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Transui -A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp -`/v1/audio/transcriptions` endpoints. It also includes a remote-only Android -speech-recognition provider and voice input method. +A compact transcription tool for Linux, macOS 15, and Android. Linux and macOS +support OpenAI-compatible hosted endpoints with an optional llama.cpp local +fallback. Android provides remote-only speech recognition and voice input. ## Dependencies @@ -14,16 +14,32 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \ libjson-glib-dev wtype xdotool ``` +On macOS, install Xcode 16 and XcodeGen. + ## Build +### Linux + ```sh cmake -S . -B build cmake --build build sudo cmake --install build ``` -Android release APKs build in Gitea CI. To run local Android checks, configure -the Android SDK and run: +### macOS + +```sh +cd macos +xcodegen generate +open Transui.xcodeproj +``` + +Build the `Transui` scheme in Xcode. + +### Android + +Android release APKs build in Gitea CI. To run local checks, configure the +Android SDK and run: ```sh cd android diff --git a/macos/Sources/Transui/AppModel.swift b/macos/Sources/Transui/AppModel.swift index 75f2292..62f1911 100644 --- a/macos/Sources/Transui/AppModel.swift +++ b/macos/Sources/Transui/AppModel.swift @@ -100,7 +100,7 @@ final class AppModel: ObservableObject { } private func startRecording() async { - guard !settings.endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard !transcriptionPlan.endpoints.isEmpty else { showFailure("Set a transcription endpoint in Settings.") return } @@ -147,9 +147,7 @@ final class AppModel: ObservableObject { do { let text = try await transcriptionClient.transcribe( recording: recording, - endpoint: settings.endpoint, - model: settings.model, - key: settings.apiKey, + plan: transcriptionPlan, prompt: settings.prompt ) let result = TranscriptOutput.deliver(text, to: settings.destination) @@ -182,6 +180,24 @@ final class AppModel: ObservableObject { } } + 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() diff --git a/macos/Sources/Transui/AppSettings.swift b/macos/Sources/Transui/AppSettings.swift index d2a615b..f5aa11f 100644 --- a/macos/Sources/Transui/AppSettings.swift +++ b/macos/Sources/Transui/AppSettings.swift @@ -18,8 +18,11 @@ enum TranscriptDestination: String, CaseIterable, Identifiable { @MainActor final class AppSettings: ObservableObject { private enum Key { - static let endpoint = "endpoint" - static let model = "model" + static let hostedEndpoint = "endpoint" + static let hostedModel = "model" + static let localEndpoint = "local_endpoint" + static let localModel = "local_model" + static let hostedFirst = "hosted_first" static let prompt = "prompt" static let destination = "destination" static let shortcut = "shortcut" @@ -27,8 +30,21 @@ final class AppSettings: ObservableObject { 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 hostedEndpoint: String { + didSet { defaults.set(hostedEndpoint, forKey: Key.hostedEndpoint) } + } + @Published var hostedModel: String { + didSet { defaults.set(hostedModel, forKey: Key.hostedModel) } + } + @Published var localEndpoint: String { + didSet { defaults.set(localEndpoint, forKey: Key.localEndpoint) } + } + @Published var localModel: String { + didSet { defaults.set(localModel, forKey: Key.localModel) } + } + @Published var hostedFirst: Bool { + didSet { defaults.set(hostedFirst, forKey: Key.hostedFirst) } + } @Published var prompt: String { didSet { defaults.set(prompt, forKey: Key.prompt) } } @Published var destination: TranscriptDestination { didSet { defaults.set(destination.rawValue, forKey: Key.destination) } @@ -43,8 +59,11 @@ final class AppSettings: ObservableObject { init(defaults: UserDefaults = .standard) { self.defaults = defaults - endpoint = defaults.string(forKey: Key.endpoint) ?? "" - model = defaults.string(forKey: Key.model) ?? "" + hostedEndpoint = defaults.string(forKey: Key.hostedEndpoint) ?? "" + hostedModel = defaults.string(forKey: Key.hostedModel) ?? "" + localEndpoint = defaults.string(forKey: Key.localEndpoint) ?? "http://localhost:8080" + localModel = defaults.string(forKey: Key.localModel) ?? "" + hostedFirst = defaults.object(forKey: Key.hostedFirst) as? Bool ?? true prompt = defaults.string(forKey: Key.prompt) ?? "" destination = TranscriptDestination( @@ -59,8 +78,13 @@ final class AppSettings: ObservableObject { } } - var apiKey: String { - get { KeychainStore.read() } - set { KeychainStore.write(newValue) } + var hostedKey: String { + get { KeychainStore.read(.hosted) } + set { KeychainStore.write(newValue, account: .hosted) } + } + + var localKey: String { + get { KeychainStore.read(.local) } + set { KeychainStore.write(newValue, account: .local) } } } diff --git a/macos/Sources/Transui/KeychainStore.swift b/macos/Sources/Transui/KeychainStore.swift index 0092b73..b2d1fec 100644 --- a/macos/Sources/Transui/KeychainStore.swift +++ b/macos/Sources/Transui/KeychainStore.swift @@ -1,15 +1,19 @@ import Foundation import Security +enum KeychainAccount: String { + case hosted = "remote-api-key" + case local = "local-api-key" +} + enum KeychainStore { private static let service = "io.github.transui.macos" - private static let account = "remote-api-key" - static func read() -> String { + static func read(_ account: KeychainAccount) -> String { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: account, + kSecAttrAccount as String: account.rawValue, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] @@ -20,11 +24,11 @@ enum KeychainStore { return String(data: data, encoding: .utf8) ?? "" } - static func write(_ value: String) { + static func write(_ value: String, account: KeychainAccount) { let identity: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: account, + kSecAttrAccount as String: account.rawValue, ] if value.isEmpty { SecItemDelete(identity as CFDictionary) diff --git a/macos/Sources/Transui/SettingsView.swift b/macos/Sources/Transui/SettingsView.swift index af1faf0..3f72f05 100644 --- a/macos/Sources/Transui/SettingsView.swift +++ b/macos/Sources/Transui/SettingsView.swift @@ -3,7 +3,8 @@ import SwiftUI struct SettingsView: View { @ObservedObject var model: AppModel @ObservedObject private var settings: AppSettings - @State private var apiKey = "" + @State private var hostedKey = "" + @State private var localKey = "" init(model: AppModel) { self.model = model @@ -12,11 +13,23 @@ struct SettingsView: View { 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 } + Section("Hosted") { + TextField( + "Endpoint", text: $settings.hostedEndpoint, prompt: Text("https://api.example.com")) + TextField("Model", text: $settings.hostedModel) + SecureField("API key", text: $hostedKey) + .onChange(of: hostedKey) { _, value in settings.hostedKey = value } + } + + Section("Local fallback") { + Toggle("Hosted first, local fallback", isOn: $settings.hostedFirst) + TextField("Endpoint", text: $settings.localEndpoint) + TextField("Model", text: $settings.localModel) + SecureField("API key", text: $localKey) + .onChange(of: localKey) { _, value in settings.localKey = value } + } + + Section("Transcript") { TextField("Prompt", text: $settings.prompt, axis: .vertical) .lineLimit(2...4) Picker("Output", selection: $settings.destination) { @@ -55,7 +68,10 @@ struct SettingsView: View { } } .formStyle(.grouped) - .frame(width: 500, height: 470) - .onAppear { apiKey = settings.apiKey } + .frame(width: 500, height: 620) + .onAppear { + hostedKey = settings.hostedKey + localKey = settings.localKey + } } } diff --git a/macos/Sources/Transui/TranscriptionClient.swift b/macos/Sources/Transui/TranscriptionClient.swift index db13330..7a3e5f3 100644 --- a/macos/Sources/Transui/TranscriptionClient.swift +++ b/macos/Sources/Transui/TranscriptionClient.swift @@ -8,16 +8,42 @@ enum TranscriptionError: LocalizedError { case invalidEndpoint case invalidResponse case http(Int) + case allEndpointsFailed([String]) 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)." + case .allEndpointsFailed(let failures): failures.joined(separator: " ") } } } +struct TranscriptionEndpoint: Equatable { + let label: String + let endpoint: String + let model: String + let key: String + + var isConfigured: Bool { + !endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct TranscriptionPlan { + let hosted: TranscriptionEndpoint + let local: TranscriptionEndpoint + let hostedFirst: Bool + + var endpoints: [TranscriptionEndpoint] { + var result: [TranscriptionEndpoint] = [] + if hostedFirst, hosted.isConfigured { result.append(hosted) } + if local.isConfigured { result.append(local) } + return result + } +} + struct TranscriptionClient { static func endpointURL(from value: String) -> URL? { var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -34,6 +60,31 @@ struct TranscriptionClient { } func transcribe( + recording: URL, + plan: TranscriptionPlan, + prompt: String + ) async throws -> String { + let endpoints = plan.endpoints + guard !endpoints.isEmpty else { throw TranscriptionError.invalidEndpoint } + + var failures: [String] = [] + for endpoint in endpoints { + do { + return try await transcribe( + recording: recording, + endpoint: endpoint.endpoint, + model: endpoint.model, + key: endpoint.key, + prompt: prompt + ) + } catch { + failures.append("\(endpoint.label): \(error.localizedDescription)") + } + } + throw TranscriptionError.allEndpointsFailed(failures) + } + + private func transcribe( recording: URL, endpoint: String, model: String, diff --git a/macos/Tests/TransuiTests/TranscriptionClientTests.swift b/macos/Tests/TransuiTests/TranscriptionClientTests.swift index 8d20ccf..a6295f5 100644 --- a/macos/Tests/TransuiTests/TranscriptionClientTests.swift +++ b/macos/Tests/TransuiTests/TranscriptionClientTests.swift @@ -23,4 +23,38 @@ final class TranscriptionClientTests: XCTestCase { XCTAssertNil(TranscriptionClient.endpointURL(from: "")) XCTAssertNil(TranscriptionClient.endpointURL(from: "example.com")) } + + func testHostedFirstPlanFallsBackToLocal() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", "https://hosted.example.com"), + local: endpoint("Local", "http://localhost:8080"), + hostedFirst: true + ) + + XCTAssertEqual(plan.endpoints.map(\.label), ["Hosted", "Local"]) + } + + func testDisabledHostedFirstUsesOnlyLocal() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", "https://hosted.example.com"), + local: endpoint("Local", "http://localhost:8080"), + hostedFirst: false + ) + + XCTAssertEqual(plan.endpoints.map(\.label), ["Local"]) + } + + func testPlanSkipsEmptyEndpoints() { + let plan = TranscriptionPlan( + hosted: endpoint("Hosted", ""), + local: endpoint("Local", ""), + hostedFirst: true + ) + + XCTAssertTrue(plan.endpoints.isEmpty) + } + + private func endpoint(_ label: String, _ value: String) -> TranscriptionEndpoint { + TranscriptionEndpoint(label: label, endpoint: value, model: "", key: "") + } }