2 Commits
Author SHA1 Message Date
intelligence 570624fb8b try again 2026-08-29 19:17:47 +02:00
intelligence 5517792387 fix and remove debug
Android / build-android (push) Successful in 2m30s
2026-08-29 19:06:16 +02:00
22 changed files with 18 additions and 1389 deletions

No files matched your search

-1
View File
@@ -4,4 +4,3 @@
/android/build/
/android/local.properties
/android/app/build/
.DS_Store
-28
View File
@@ -1,28 +0,0 @@
# 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.
- 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.
## llama.cpp local endpoint
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
curl -s -X POST -F response_format=json http://localhost:8080/v1/audio/transcriptions \
-F file="@test.mp3" \
-F 'prompt=Transcribe the following speech segment in English, and translate them into chinese.'
```
llama.cpp may return an event envelope instead of a bare OpenAI response:
```json
{"type":"transcript.text.done","text":"这是个测试。你能听到什么?我希望这很好。","usage":{"type":"tokens","input_tokens":241,"output_tokens":516,"total_tokens":757,"input_tokens_details":{"cached_tokens":0}}}
```
Use `text` as the transcript. Accept `transcript.text.done`; do not require a
bare `{ "text": "…" }` response.
+18 -26
View File
@@ -1,8 +1,7 @@
# Transui
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.
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp
`/v1/audio/transcriptions` endpoints.
## Dependencies
@@ -14,38 +13,19 @@ 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.
`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so
you only need the one matching your session. API keys are saved in the
freedesktop Secret Service (GNOME Keyring, KWallet Secret Service, and so on),
not in the settings file.
## Build
### Linux
```sh
cmake -S . -B build
cmake --build build
sudo cmake --install build
```
### 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
./gradlew lintDebug lintRelease testDebugUnitTest
```
## Commands
```sh
@@ -53,3 +33,15 @@ transui settings # configure endpoints, prompt, and destination
transui record # open the record window
transui # same as record
```
Bind `transui record` to a shortcut in your desktop environment. Transui does
not register a global shortcut itself. Press the same shortcut again to stop
and transcribe the current recording. Audio is recorded as 16 kHz mono MP3 at
32 kbps to keep uploads small.
The **Hosted first, local fallback** setting tries the Hosted endpoint, then
the Local endpoint when the first request fails. With it disabled, Transui uses
the Local endpoint only.
Both a normal response (`{"text":"..."}`) and llama.cpp event response
(`{"type":"transcript.text.done","text":"..."}`) are accepted.
-2
View File
@@ -1,2 +0,0 @@
DerivedData/
Transui.xcodeproj/
-35
View File
@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Transui</string>
<key>CFBundleIdentifier</key>
<string>io.github.transui.macos</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Transui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Transui records speech to transcribe it.</string>
</dict>
</plist>
-237
View File
@@ -1,237 +0,0 @@
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 !transcriptionPlan.endpoints.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,
plan: transcriptionPlan,
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 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()
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)
}
}
-90
View File
@@ -1,90 +0,0 @@
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 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"
}
private let defaults: UserDefaults
@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) }
}
@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
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(
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 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) }
}
}
-86
View File
@@ -1,86 +0,0 @@
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
}
}
}
-70
View File
@@ -1,70 +0,0 @@
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<EventHotKeyID>.size,
nil,
&identifier
)
guard status == noErr, identifier.id == 1 else { return status }
let manager = Unmanaged<HotkeyManager>.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
}
@@ -1,59 +0,0 @@
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()
}
}
@@ -1,67 +0,0 @@
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",
]
}
-45
View File
@@ -1,45 +0,0 @@
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"
static func read(_ account: KeychainAccount) -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account.rawValue,
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, account: KeychainAccount) {
let identity: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account.rawValue,
]
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)
}
}
}
-133
View File
@@ -1,133 +0,0 @@
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)
}
}
-77
View File
@@ -1,77 +0,0 @@
import SwiftUI
struct SettingsView: View {
@ObservedObject var model: AppModel
@ObservedObject private var settings: AppSettings
@State private var hostedKey = ""
@State private var localKey = ""
init(model: AppModel) {
self.model = model
settings = model.settings
}
var body: some View {
Form {
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) {
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: 620)
.onAppear {
hostedKey = settings.hostedKey
localKey = settings.localKey
}
}
}
@@ -1,20 +0,0 @@
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 = ["<asr_text>", "<astext>"]
let marker = markers.compactMap { text.range(of: $0, range: prefix..<text.endIndex) }
.min { $0.lowerBound < $1.lowerBound }
guard let marker else { return text }
return String(text[marker.upperBound...])
}
}
@@ -1,43 +0,0 @@
import AppKit
import ApplicationServices
import Carbon.HIToolbox
enum OutputResult {
case typed
case copied
case copiedBecauseAccessibilityIsOff
}
enum TranscriptOutput {
@MainActor
static func deliver(_ text: String, to destination: TranscriptDestination) -> 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
}
}
@@ -1,161 +0,0 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
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)
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,
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,
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)
}
}
-44
View File
@@ -1,44 +0,0 @@
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)
}
}
@@ -1,30 +0,0 @@
import XCTest
@testable import Transui
final class TranscriptCleanerTests: XCTestCase {
func testRemovesAsTextPrefix() {
XCTAssertEqual(
TranscriptCleaner.clean("language English<astext>Hello there"),
"Hello there"
)
}
func testRemovesAsrTextPrefix() {
XCTAssertEqual(
TranscriptCleaner.clean("language zh <asr_text>你好"),
"你好"
)
}
func testRemovesPrefixAfterWhitespaceAndBom() {
XCTAssertEqual(
TranscriptCleaner.clean("\u{FEFF} \n\tlanguage English<astext>Hello"),
"Hello"
)
}
func testPreservesOrdinaryTranscript() {
XCTAssertEqual(TranscriptCleaner.clean("language is useful"), "language is useful")
}
}
@@ -1,60 +0,0 @@
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"))
}
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: "")
}
}
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
-67
View File
@@ -1,67 +0,0 @@
name: Transui
options:
createIntermediateGroups: true
deploymentTarget:
macOS: "15.0"
xcodeVersion: "16.0"
configs:
Debug: debug
Release: release
settings:
base:
CODE_SIGN_STYLE: Automatic
CURRENT_PROJECT_VERSION: 1
MARKETING_VERSION: 0.1.0
SWIFT_VERSION: 5.0
targets:
Transui:
type: application
platform: macOS
deploymentTarget: "15.0"
sources:
- path: Sources/Transui
settings:
base:
CODE_SIGN_ENTITLEMENTS: Transui.entitlements
ENABLE_HARDENED_RUNTIME: "YES"
GENERATE_INFOPLIST_FILE: "NO"
INFOPLIST_FILE: Info.plist
PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos
PRODUCT_NAME: Transui
TransuiTests:
type: bundle.unit-test
platform: macOS
deploymentTarget: "15.0"
sources:
- path: Tests/TransuiTests
dependencies:
- target: Transui
settings:
base:
GENERATE_INFOPLIST_FILE: "YES"
PRODUCT_BUNDLE_IDENTIFIER: io.github.transui.macos.tests
schemes:
Transui:
build:
targets:
Transui: all
TransuiTests: [test]
run:
config: Debug
test:
config: Debug
gatherCoverageData: true
targets:
- TransuiTests
profile:
config: Release
analyze:
config: Debug
archive:
config: Release