Compare commits
2
Commits
6011b85abb
...
77c987ec1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c987ec1e | ||
|
|
c33d87b06e |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 15, 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user