3 Commits
Author SHA1 Message Date
intelligence c02f3f0dcb chore:README 2026-08-29 20:29:39 +02:00
intelligence 77c987ec1e Amerge 2026-08-29 20:26:47 +02:00
intelligence c33d87b06e fix:feature-parity 2026-08-29 20:25:14 +02:00
8 changed files with 196 additions and 33 deletions

No files matched your search

+4 -2
View File
@@ -1,13 +1,15 @@
# Transui design # 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. - 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. - 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 …<astext>` or `language …<asr_text>` prefix. - All clients call OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …<astext>` or `language …<asr_text>` 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. - 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 ## 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. is required; `prompt` is optional and may request transcription or translation.
```sh ```sh
+21 -5
View File
@@ -1,8 +1,8 @@
# Transui # Transui
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp A compact transcription tool for Linux, macOS, and Android. Linux and macOS
`/v1/audio/transcriptions` endpoints. It also includes a remote-only Android support OpenAI-compatible hosted endpoints with an optional llama.cpp local
speech-recognition provider and voice input method. fallback. Android provides remote-only speech recognition and voice input.
## Dependencies ## Dependencies
@@ -14,16 +14,32 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \
libjson-glib-dev wtype xdotool libjson-glib-dev wtype xdotool
``` ```
On macOS, install Xcode 16 and XcodeGen.
## Build ## Build
### Linux
```sh ```sh
cmake -S . -B build cmake -S . -B build
cmake --build build cmake --build build
sudo cmake --install build sudo cmake --install build
``` ```
Android release APKs build in Gitea CI. To run local Android checks, configure ### macOS
the Android SDK and run:
```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 ```sh
cd android cd android
+20 -4
View File
@@ -100,7 +100,7 @@ final class AppModel: ObservableObject {
} }
private func startRecording() async { private func startRecording() async {
guard !settings.endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !transcriptionPlan.endpoints.isEmpty else {
showFailure("Set a transcription endpoint in Settings.") showFailure("Set a transcription endpoint in Settings.")
return return
} }
@@ -147,9 +147,7 @@ final class AppModel: ObservableObject {
do { do {
let text = try await transcriptionClient.transcribe( let text = try await transcriptionClient.transcribe(
recording: recording, recording: recording,
endpoint: settings.endpoint, plan: transcriptionPlan,
model: settings.model,
key: settings.apiKey,
prompt: settings.prompt prompt: settings.prompt
) )
let result = TranscriptOutput.deliver(text, to: settings.destination) 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) { private func showSuccess(_ message: String) {
state = .success(message) state = .success(message)
overlay.show() overlay.show()
+33 -9
View File
@@ -18,8 +18,11 @@ enum TranscriptDestination: String, CaseIterable, Identifiable {
@MainActor @MainActor
final class AppSettings: ObservableObject { final class AppSettings: ObservableObject {
private enum Key { private enum Key {
static let endpoint = "endpoint" static let hostedEndpoint = "endpoint"
static let model = "model" 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 prompt = "prompt"
static let destination = "destination" static let destination = "destination"
static let shortcut = "shortcut" static let shortcut = "shortcut"
@@ -27,8 +30,21 @@ final class AppSettings: ObservableObject {
private let defaults: UserDefaults private let defaults: UserDefaults
@Published var endpoint: String { didSet { defaults.set(endpoint, forKey: Key.endpoint) } } @Published var hostedEndpoint: String {
@Published var model: String { didSet { defaults.set(model, forKey: Key.model) } } 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 prompt: String { didSet { defaults.set(prompt, forKey: Key.prompt) } }
@Published var destination: TranscriptDestination { @Published var destination: TranscriptDestination {
didSet { defaults.set(destination.rawValue, forKey: Key.destination) } didSet { defaults.set(destination.rawValue, forKey: Key.destination) }
@@ -43,8 +59,11 @@ final class AppSettings: ObservableObject {
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
endpoint = defaults.string(forKey: Key.endpoint) ?? "" hostedEndpoint = defaults.string(forKey: Key.hostedEndpoint) ?? ""
model = defaults.string(forKey: Key.model) ?? "" 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) ?? "" prompt = defaults.string(forKey: Key.prompt) ?? ""
destination = destination =
TranscriptDestination( TranscriptDestination(
@@ -59,8 +78,13 @@ final class AppSettings: ObservableObject {
} }
} }
var apiKey: String { var hostedKey: String {
get { KeychainStore.read() } get { KeychainStore.read(.hosted) }
set { KeychainStore.write(newValue) } set { KeychainStore.write(newValue, account: .hosted) }
}
var localKey: String {
get { KeychainStore.read(.local) }
set { KeychainStore.write(newValue, account: .local) }
} }
} }
+9 -5
View File
@@ -1,15 +1,19 @@
import Foundation import Foundation
import Security import Security
enum KeychainAccount: String {
case hosted = "remote-api-key"
case local = "local-api-key"
}
enum KeychainStore { enum KeychainStore {
private static let service = "io.github.transui.macos" 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] = [ let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccount as String: account, kSecAttrAccount as String: account.rawValue,
kSecReturnData as String: true, kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne, kSecMatchLimit as String: kSecMatchLimitOne,
] ]
@@ -20,11 +24,11 @@ enum KeychainStore {
return String(data: data, encoding: .utf8) ?? "" return String(data: data, encoding: .utf8) ?? ""
} }
static func write(_ value: String) { static func write(_ value: String, account: KeychainAccount) {
let identity: [String: Any] = [ let identity: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccount as String: account, kSecAttrAccount as String: account.rawValue,
] ]
if value.isEmpty { if value.isEmpty {
SecItemDelete(identity as CFDictionary) SecItemDelete(identity as CFDictionary)
+24 -8
View File
@@ -3,7 +3,8 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var model: AppModel @ObservedObject var model: AppModel
@ObservedObject private var settings: AppSettings @ObservedObject private var settings: AppSettings
@State private var apiKey = "" @State private var hostedKey = ""
@State private var localKey = ""
init(model: AppModel) { init(model: AppModel) {
self.model = model self.model = model
@@ -12,11 +13,23 @@ struct SettingsView: View {
var body: some View { var body: some View {
Form { Form {
Section("Transcription") { Section("Hosted") {
TextField("Endpoint", text: $settings.endpoint, prompt: Text("https://api.example.com")) TextField(
TextField("Model", text: $settings.model) "Endpoint", text: $settings.hostedEndpoint, prompt: Text("https://api.example.com"))
SecureField("API key", text: $apiKey) TextField("Model", text: $settings.hostedModel)
.onChange(of: apiKey) { _, value in settings.apiKey = value } 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) TextField("Prompt", text: $settings.prompt, axis: .vertical)
.lineLimit(2...4) .lineLimit(2...4)
Picker("Output", selection: $settings.destination) { Picker("Output", selection: $settings.destination) {
@@ -55,7 +68,10 @@ struct SettingsView: View {
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
.frame(width: 500, height: 470) .frame(width: 500, height: 620)
.onAppear { apiKey = settings.apiKey } .onAppear {
hostedKey = settings.hostedKey
localKey = settings.localKey
}
} }
} }
@@ -8,16 +8,42 @@ enum TranscriptionError: LocalizedError {
case invalidEndpoint case invalidEndpoint
case invalidResponse case invalidResponse
case http(Int) case http(Int)
case allEndpointsFailed([String])
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .invalidEndpoint: "The transcription endpoint is invalid." case .invalidEndpoint: "The transcription endpoint is invalid."
case .invalidResponse: "The server response did not contain a transcript." case .invalidResponse: "The server response did not contain a transcript."
case .http(let status): "The server returned HTTP \(status)." 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 { struct TranscriptionClient {
static func endpointURL(from value: String) -> URL? { static func endpointURL(from value: String) -> URL? {
var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines) var endpoint = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -34,6 +60,31 @@ struct TranscriptionClient {
} }
func transcribe( 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, recording: URL,
endpoint: String, endpoint: String,
model: String, model: String,
@@ -23,4 +23,38 @@ final class TranscriptionClientTests: XCTestCase {
XCTAssertNil(TranscriptionClient.endpointURL(from: "")) XCTAssertNil(TranscriptionClient.endpointURL(from: ""))
XCTAssertNil(TranscriptionClient.endpointURL(from: "example.com")) 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: "")
}
} }