3 Commits
Author SHA1 Message Date
intelligence 7ed3adf208 feat:building-with-version-number
Android / build-android (release) Successful in 3m39s
Build / build-amd64 (release) Successful in 43s
Build / build-arm64 (release) Successful in 1m32s
2026-08-30 00:20:52 +02:00
jourintownandintelligence 64b9d704fb feat:mp3 (#3)
---------

Co-authored-by: intelligence <intelligence@debian.local>
Reviewed-on: #3
2026-08-29 23:55:02 +02:00
jourintownandintelligence 32e7f32e0a Feat macos (#2)
Android / build-android (release) Successful in 2m18s
Build / build-amd64 (release) Successful in 34s
Build / build-arm64 (release) Canceled after 0s
---------

Co-authored-by: intelligence <intelligence@debian.local>
Co-authored-by: jourintown <jourintown@proton.me>
Reviewed-on: #2
2026-08-29 20:30:30 +02:00
42 changed files with 1911 additions and 102 deletions

No files matched your search

+34
View File
@@ -13,6 +13,8 @@ jobs:
releases: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -20,6 +22,38 @@ jobs:
- uses: android-actions/setup-android@v3
# Gitea's act resolver cannot peel the annotated v4 tag alias.
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
- name: Set release version
if: gitea.event_name == 'release'
shell: bash
working-directory: android
env:
TRANSUI_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
run: |
version="${TRANSUI_RELEASE_TAG#v}"
if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Release tag must use vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH" >&2
exit 1
fi
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
patch=$((10#${BASH_REMATCH[3]}))
if (( minor > 999 || patch > 999 )); then
echo "Minor and patch versions must be below 1000" >&2
exit 1
fi
version_code=$((major * 1000000 + minor * 1000 + patch))
if (( version_code < 1 || version_code > 2100000000 )); then
echo "Release version is outside Android's versionCode range" >&2
exit 1
fi
echo "TRANSUI_VERSION=$version" >> "$GITEA_ENV"
echo "TRANSUI_VERSION_CODE=$version_code" >> "$GITEA_ENV"
sed -i -E \
-e "s/^([[:space:]]*)versionCode .*/\1versionCode $version_code/" \
-e "s/^([[:space:]]*)versionName .*/\1versionName '$version'/" \
app/build.gradle
grep -Fq " versionCode $version_code" app/build.gradle
grep -Fq " versionName '$version'" app/build.gradle
- name: Install Android SDK packages
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
- name: Lint and test
+40 -2
View File
@@ -15,6 +15,20 @@ jobs:
CMAKE_BUILD_PARALLEL_LEVEL: 2
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set release version
if: gitea.event_name == 'release'
shell: bash
env:
TRANSUI_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
run: |
version="${TRANSUI_RELEASE_TAG#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Release tag must use vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH" >&2
exit 1
fi
echo "TRANSUI_VERSION=$version" >> "$GITEA_ENV"
- name: Install dependencies
run: |
apt-get update
@@ -23,7 +37,12 @@ jobs:
libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good libsecret-1-dev \
libjson-glib-dev wtype xdotool
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
run: |
version_args=()
if [ -n "${TRANSUI_VERSION:-}" ]; then
version_args+=("-DCPACK_PACKAGE_VERSION=$TRANSUI_VERSION")
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release "${version_args[@]}"
- name: Build
run: cmake --build build --parallel 2
- name: Package
@@ -58,6 +77,20 @@ jobs:
CMAKE_BUILD_PARALLEL_LEVEL: 2
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set release version
if: gitea.event_name == 'release'
shell: bash
env:
TRANSUI_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
run: |
version="${TRANSUI_RELEASE_TAG#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Release tag must use vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH" >&2
exit 1
fi
echo "TRANSUI_VERSION=$version" >> "$GITEA_ENV"
- name: Install dependencies
run: |
apt-get update
@@ -66,7 +99,12 @@ jobs:
libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good libsecret-1-dev \
libjson-glib-dev wtype xdotool
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
run: |
version_args=()
if [ -n "${TRANSUI_VERSION:-}" ]; then
version_args+=("-DCPACK_PACKAGE_VERSION=$TRANSUI_VERSION")
fi
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release "${version_args[@]}"
- name: Build
run: cmake --build build --parallel 2
- name: Package
+1
View File
@@ -4,3 +4,4 @@
/android/build/
/android/local.properties
/android/app/build/
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
[submodule "third_party/shine"]
path = third_party/shine
url = https://github.com/toots/shine.git
+5 -2
View File
@@ -1,13 +1,16 @@
# 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 …<astext>` or `language …<asr_text>` prefix.
- All clients upload 16 kHz mono, 32 kbps MP3 to OpenAI-compatible `/v1/audio/transcriptions` endpoints and clean only the Qwen3-ASR `language …<astext>` or `language …<asr_text>` prefix.
- MP3 encoding uses the `third_party/shine` submodule pinned to `ab5e3526b64af1a2eaa43aa6f441a7312e013519` (`3.1.1-59-gab5e352`).
- 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
+21 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(transui VERSION 0.1.1 LANGUAGES CXX)
project(transui VERSION 0.1.1 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -31,6 +31,26 @@ if(BUILD_TESTING)
)
target_include_directories(transcript_cleaner_test PRIVATE src)
add_test(NAME transcript_cleaner COMMAND transcript_cleaner_test)
add_executable(mp3_encoder_test
tests/mp3_encoder_test.c
third_party/transui_mp3/transui_mp3.c
third_party/shine/src/lib/bitstream.c
third_party/shine/src/lib/huffman.c
third_party/shine/src/lib/l3bitstream.c
third_party/shine/src/lib/l3loop.c
third_party/shine/src/lib/l3mdct.c
third_party/shine/src/lib/l3subband.c
third_party/shine/src/lib/layer3.c
third_party/shine/src/lib/reservoir.c
third_party/shine/src/lib/tables.c
)
target_include_directories(mp3_encoder_test PRIVATE
third_party/transui_mp3
third_party/shine/src/lib
)
target_link_libraries(mp3_encoder_test PRIVATE m)
add_test(NAME mp3_encoder COMMAND mp3_encoder_test)
endif()
set(CPACK_GENERATOR "DEB")
+23 -5
View File
@@ -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, 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,34 @@ 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.
For Android, install Android SDK 35, the Android NDK, and CMake.
## 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 NDK, then run:
```sh
cd android
+12
View File
@@ -14,6 +14,12 @@ android {
versionName '0.1.1'
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
externalNativeBuild {
cmake {
arguments '-DANDROID_STL=none'
}
}
}
signingConfigs {
@@ -46,6 +52,12 @@ android {
checkReleaseBuilds true
warningsAsErrors true
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
}
}
}
dependencies {
+24
View File
@@ -0,0 +1,24 @@
cmake_minimum_required(VERSION 3.22.1)
project(transui_mp3 LANGUAGES C)
set(SHINE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/shine/src/lib")
set(TRANSUI_MP3_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../third_party/transui_mp3")
add_library(transui_mp3 SHARED
native_mp3_encoder.c
"${TRANSUI_MP3_DIR}/transui_mp3.c"
"${SHINE_DIR}/bitstream.c"
"${SHINE_DIR}/huffman.c"
"${SHINE_DIR}/l3bitstream.c"
"${SHINE_DIR}/l3loop.c"
"${SHINE_DIR}/l3mdct.c"
"${SHINE_DIR}/l3subband.c"
"${SHINE_DIR}/layer3.c"
"${SHINE_DIR}/reservoir.c"
"${SHINE_DIR}/tables.c"
)
target_compile_features(transui_mp3 PRIVATE c_std_11)
target_compile_options(transui_mp3 PRIVATE -Wall -Wextra)
target_include_directories(transui_mp3 PRIVATE "${TRANSUI_MP3_DIR}" "${SHINE_DIR}")
target_link_libraries(transui_mp3 PRIVATE m)
@@ -0,0 +1,33 @@
#include <jni.h>
#include "transui_mp3.h"
JNIEXPORT jbyteArray JNICALL
Java_io_github_transui_android_Mp3Encoder_encodeNative(JNIEnv *env,
jclass type,
jbyteArray pcm) {
(void)type;
if (!pcm) return NULL;
const jsize pcm_size = (*env)->GetArrayLength(env, pcm);
jbyte *pcm_bytes = (*env)->GetByteArrayElements(env, pcm, NULL);
if (!pcm_bytes) return NULL;
uint8_t *mp3 = NULL;
size_t mp3_size = 0;
const int result = transui_mp3_encode_mono_16k_32kbps(
(const uint8_t *)pcm_bytes, (size_t)pcm_size, &mp3, &mp3_size);
(*env)->ReleaseByteArrayElements(env, pcm, pcm_bytes, JNI_ABORT);
if (result != TRANSUI_MP3_OK || !mp3 || mp3_size > INT32_MAX) {
transui_mp3_free(mp3);
return NULL;
}
jbyteArray output = (*env)->NewByteArray(env, (jsize)mp3_size);
if (output) {
(*env)->SetByteArrayRegion(env, output, 0, (jsize)mp3_size,
(const jbyte *)mp3);
}
transui_mp3_free(mp3);
return output;
}
@@ -0,0 +1,22 @@
package io.github.transui.android;
final class Mp3Encoder {
static {
System.loadLibrary("transui_mp3");
}
private Mp3Encoder() {}
static byte[] encodePcm16Mono(byte[] pcm) {
if (pcm == null || pcm.length == 0 || pcm.length % 2 != 0) {
throw new IllegalArgumentException("PCM input must contain complete 16-bit samples");
}
byte[] mp3 = encodeNative(pcm);
if (mp3 == null || mp3.length == 0) {
throw new IllegalStateException("MP3 encoding failed");
}
return mp3;
}
private static native byte[] encodeNative(byte[] pcm);
}
@@ -19,11 +19,11 @@ final class TranscriptionClient {
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private volatile HttpURLConnection activeConnection;
Result transcribe(AppConfig config, byte[] wav) {
Result transcribe(AppConfig config, byte[] mp3) {
if (config.endpoint.isEmpty()) {
return Result.error("No transcription endpoint is configured", SpeechRecognizer.ERROR_CLIENT);
}
return request(config.endpoint, config.model, config.apiKey, config.prompt, wav);
return request(config.endpoint, config.model, config.apiKey, config.prompt, mp3);
}
void cancel() {
@@ -36,11 +36,11 @@ final class TranscriptionClient {
String model,
String key,
String prompt,
byte[] wav) {
byte[] mp3) {
HttpURLConnection connection = null;
try {
String boundary = "transui-" + UUID.randomUUID();
byte[] requestBody = multipartBody(boundary, model, prompt, wav);
byte[] requestBody = multipartBody(boundary, model, prompt, mp3);
connection = (HttpURLConnection) new URL(endpointUrl(endpoint)).openConnection();
activeConnection = connection;
connection.setConnectTimeout(12_000);
@@ -86,17 +86,17 @@ final class TranscriptionClient {
return result + "/v1/audio/transcriptions";
}
private static byte[] multipartBody(String boundary, String model, String prompt, byte[] wav)
private static byte[] multipartBody(String boundary, String model, String prompt, byte[] mp3)
throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(wav.length + 1024);
ByteArrayOutputStream bytes = new ByteArrayOutputStream(mp3.length + 1024);
try (DataOutputStream output = new DataOutputStream(bytes)) {
writeField(output, boundary, "response_format", "json");
if (!model.isEmpty()) writeField(output, boundary, "model", model);
if (!prompt.isEmpty()) writeField(output, boundary, "prompt", prompt);
output.writeBytes("--" + boundary + "\r\n");
output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n");
output.writeBytes("Content-Type: audio/wav\r\n\r\n");
output.write(wav);
output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"recording.mp3\"\r\n");
output.writeBytes("Content-Type: audio/mpeg\r\n\r\n");
output.write(mp3);
output.writeBytes("\r\n--" + boundary + "--\r\n");
}
return bytes.toByteArray();
@@ -124,8 +124,15 @@ public final class TransuiRecognitionService extends RecognitionService {
if (pcm == null || cancelled.get()) return;
sendEndOfSpeech();
if (cancelled.get()) return;
TranscriptionClient.Result result = client.transcribe(
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
byte[] mp3;
try {
mp3 = Mp3Encoder.encodePcm16Mono(pcm);
} catch (IllegalArgumentException | IllegalStateException error) {
Log.e(TAG, "MP3 encoding failed", error);
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return;
}
TranscriptionClient.Result result = client.transcribe(config, mp3);
if (cancelled.get()) return;
if (result.text == null) {
Log.w(TAG, result.error);
@@ -67,8 +67,14 @@ final class VoiceRecordingController {
byte[] pcm = captureAudio();
if (pcm == null || cancelled.get()) return;
listener.onTranscribing();
TranscriptionClient.Result result = client.transcribe(
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
byte[] mp3;
try {
mp3 = Mp3Encoder.encodePcm16Mono(pcm);
} catch (IllegalArgumentException | IllegalStateException error) {
listener.onError(SpeechRecognizer.ERROR_AUDIO);
return;
}
TranscriptionClient.Result result = client.transcribe(config, mp3);
if (cancelled.get()) return;
if (result.text != null) {
listener.onResult(result.text);
@@ -1,44 +0,0 @@
package io.github.transui.android;
import java.io.ByteArrayOutputStream;
final class WavEncoder {
private static final int HEADER_SIZE = 44;
private WavEncoder() {}
static byte[] encodePcm16Mono(byte[] pcm, int sampleRate) {
ByteArrayOutputStream output = new ByteArrayOutputStream(HEADER_SIZE + pcm.length);
writeAscii(output, "RIFF");
writeInt(output, 36 + pcm.length);
writeAscii(output, "WAVE");
writeAscii(output, "fmt ");
writeInt(output, 16);
writeShort(output, 1);
writeShort(output, 1);
writeInt(output, sampleRate);
writeInt(output, sampleRate * 2);
writeShort(output, 2);
writeShort(output, 16);
writeAscii(output, "data");
writeInt(output, pcm.length);
output.write(pcm, 0, pcm.length);
return output.toByteArray();
}
private static void writeAscii(ByteArrayOutputStream output, String value) {
for (int index = 0; index < value.length(); index++) output.write(value.charAt(index));
}
private static void writeShort(ByteArrayOutputStream output, int value) {
output.write(value & 0xff);
output.write((value >>> 8) & 0xff);
}
private static void writeInt(ByteArrayOutputStream output, int value) {
output.write(value & 0xff);
output.write((value >>> 8) & 0xff);
output.write((value >>> 16) & 0xff);
output.write((value >>> 24) & 0xff);
}
}
@@ -1,35 +0,0 @@
package io.github.transui.android;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import java.nio.charset.StandardCharsets;
import org.junit.Test;
public final class WavEncoderTest {
@Test
public void writesPcm16MonoHeaderAndPayload() {
byte[] pcm = new byte[]{1, 2, 3, 4};
byte[] wav = WavEncoder.encodePcm16Mono(pcm, 16_000);
assertEquals("RIFF", ascii(wav, 0, 4));
assertEquals(40, littleEndianInt(wav, 4));
assertEquals("WAVE", ascii(wav, 8, 4));
assertEquals(16_000, littleEndianInt(wav, 24));
assertEquals(32_000, littleEndianInt(wav, 28));
assertEquals(4, littleEndianInt(wav, 40));
assertArrayEquals(pcm, new byte[]{wav[44], wav[45], wav[46], wav[47]});
}
private static String ascii(byte[] bytes, int offset, int length) {
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
}
private static int littleEndianInt(byte[] bytes, int offset) {
return (bytes[offset] & 0xff)
| ((bytes[offset + 1] & 0xff) << 8)
| ((bytes[offset + 2] & 0xff) << 16)
| ((bytes[offset + 3] & 0xff) << 24);
}
}
+2
View File
@@ -0,0 +1,2 @@
DerivedData/
Transui.xcodeproj/
+35
View File
@@ -0,0 +1,35 @@
<?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>
+241
View File
@@ -0,0 +1,241 @@
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 encoded = try await Task.detached(priority: .userInitiated) {
try MP3Encoder.encode(wav: recording)
}.value
defer { try? FileManager.default.removeItem(at: encoded) }
let text = try await transcriptionClient.transcribe(
recording: encoded,
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
@@ -0,0 +1,90 @@
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
@@ -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
}
}
}
+70
View File
@@ -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<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
}
@@ -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()
}
}
@@ -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",
]
}
+45
View File
@@ -0,0 +1,45 @@
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)
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import Foundation
enum MP3EncodingError: LocalizedError {
case invalidRecording
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidRecording: "The recording could not be read."
case .encodingFailed: "The recording could not be compressed."
}
}
}
enum MP3Encoder {
static func encode(wav url: URL) throws -> URL {
let wav = try Data(contentsOf: url, options: .mappedIfSafe)
let pcm = try pcmData(fromWAV: wav)
var encodedBytes: UnsafeMutablePointer<UInt8>?
var encodedSize = 0
let result = pcm.withUnsafeBytes { bytes in
transui_mp3_encode_mono_16k_32kbps(
bytes.bindMemory(to: UInt8.self).baseAddress,
bytes.count,
&encodedBytes,
&encodedSize
)
}
guard result == TRANSUI_MP3_OK, let encodedBytes, encodedSize > 0 else {
transui_mp3_free(encodedBytes)
throw MP3EncodingError.encodingFailed
}
defer { transui_mp3_free(encodedBytes) }
let output = FileManager.default.temporaryDirectory
.appendingPathComponent("transui-\(UUID().uuidString).mp3")
try Data(bytes: encodedBytes, count: encodedSize).write(to: output, options: .atomic)
return output
}
static func pcmData(fromWAV wav: Data) throws -> Data {
guard wav.count >= 12,
String(decoding: wav[0..<4], as: UTF8.self) == "RIFF",
String(decoding: wav[8..<12], as: UTF8.self) == "WAVE"
else { throw MP3EncodingError.invalidRecording }
var formatIsSupported = false
var audio: Data?
var offset = 12
while offset <= wav.count - 8 {
let chunkName = String(decoding: wav[offset..<(offset + 4)], as: UTF8.self)
let chunkSize = Int(readUInt32(wav, at: offset + 4))
let contentOffset = offset + 8
guard chunkSize >= 0, contentOffset <= wav.count,
chunkSize <= wav.count - contentOffset
else { throw MP3EncodingError.invalidRecording }
if chunkName == "fmt ", chunkSize >= 16 {
formatIsSupported = readUInt16(wav, at: contentOffset) == 1
&& readUInt16(wav, at: contentOffset + 2) == 1
&& readUInt32(wav, at: contentOffset + 4) == 16_000
&& readUInt16(wav, at: contentOffset + 14) == 16
} else if chunkName == "data" {
audio = wav.subdata(in: contentOffset..<(contentOffset + chunkSize))
}
let paddedSize = chunkSize + (chunkSize & 1)
guard paddedSize <= Int.max - contentOffset else {
throw MP3EncodingError.invalidRecording
}
offset = contentOffset + paddedSize
}
guard formatIsSupported, let audio, !audio.isEmpty, audio.count.isMultiple(of: 2) else {
throw MP3EncodingError.invalidRecording
}
return audio
}
private static func readUInt16(_ data: Data, at offset: Int) -> UInt16 {
UInt16(data[offset]) | UInt16(data[offset + 1]) << 8
}
private static func readUInt32(_ data: Data, at offset: Int) -> UInt32 {
UInt32(data[offset])
| UInt32(data[offset + 1]) << 8
| UInt32(data[offset + 2]) << 16
| UInt32(data[offset + 3]) << 24
}
}
+133
View File
@@ -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)
}
}
+77
View File
@@ -0,0 +1,77 @@
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
}
}
}
@@ -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 = ["<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...])
}
}
@@ -0,0 +1,43 @@
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
}
}
@@ -0,0 +1,161 @@
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.mp3\"\r\n")
data.append("Content-Type: audio/mpeg\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)
}
}
@@ -0,0 +1 @@
#include "transui_mp3.h"
+44
View File
@@ -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)
}
}
@@ -0,0 +1,45 @@
import XCTest
@testable import Transui
final class MP3EncoderTests: XCTestCase {
func testExtractsSupportedPCMDataFromWAV() throws {
let samples = Data([0x01, 0x02, 0x03, 0x04])
let wav = makeWAV(samples: samples)
XCTAssertEqual(try MP3Encoder.pcmData(fromWAV: wav), samples)
}
func testRejectsWrongSampleRate() {
XCTAssertThrowsError(try MP3Encoder.pcmData(fromWAV: makeWAV(sampleRate: 44_100)))
}
private func makeWAV(sampleRate: UInt32 = 16_000, samples: Data = Data([0, 0])) -> Data {
var data = Data("RIFF".utf8)
append(UInt32(36 + samples.count), to: &data)
data.append(Data("WAVEfmt ".utf8))
append(UInt32(16), to: &data)
append(UInt16(1), to: &data)
append(UInt16(1), to: &data)
append(sampleRate, to: &data)
append(sampleRate * 2, to: &data)
append(UInt16(2), to: &data)
append(UInt16(16), to: &data)
data.append(Data("data".utf8))
append(UInt32(samples.count), to: &data)
data.append(samples)
return data
}
private func append(_ value: UInt16, to data: inout Data) {
data.append(UInt8(value & 0xff))
data.append(UInt8(value >> 8))
}
private func append(_ value: UInt32, to data: inout Data) {
data.append(UInt8(value & 0xff))
data.append(UInt8((value >> 8) & 0xff))
data.append(UInt8((value >> 16) & 0xff))
data.append(UInt8((value >> 24) & 0xff))
}
}
@@ -0,0 +1,30 @@
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")
}
}
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,8 @@
<?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>
+74
View File
@@ -0,0 +1,74 @@
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
- path: ../third_party/transui_mp3
- path: ../third_party/shine/src/lib
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
GCC_C_LANGUAGE_STANDARD: c11
HEADER_SEARCH_PATHS:
- "$(SRCROOT)/../third_party/transui_mp3"
- "$(SRCROOT)/../third_party/shine/src/lib"
SWIFT_OBJC_BRIDGING_HEADER: Sources/Transui/Transui-Bridging-Header.h
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
+33
View File
@@ -0,0 +1,33 @@
#include "transui_mp3.h"
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
const size_t pcm_size = 16000 * 2;
uint8_t *pcm = calloc(pcm_size, 1);
assert(pcm != NULL);
uint8_t *mp3 = NULL;
size_t mp3_size = 0;
assert(transui_mp3_encode_mono_16k_32kbps(pcm, pcm_size, &mp3,
&mp3_size) == TRANSUI_MP3_OK);
assert(mp3 != NULL);
assert(mp3_size > 2);
assert(mp3[0] == 0xff);
assert((mp3[1] & 0xe0) == 0xe0);
if (argc == 2) {
FILE *output = fopen(argv[1], "wb");
assert(output != NULL);
assert(fwrite(mp3, 1, mp3_size, output) == mp3_size);
assert(fclose(output) == 0);
}
transui_mp3_free(mp3);
free(pcm);
assert(transui_mp3_encode_mono_16k_32kbps(NULL, 0, &mp3, &mp3_size) ==
TRANSUI_MP3_INVALID_INPUT);
return 0;
}
Vendored Submodule
+1
Submodule third_party/shine added at ab5e3526b6.
+123
View File
@@ -0,0 +1,123 @@
#include "transui_mp3.h"
#include "layer3.h"
#include <stdlib.h>
#include <string.h>
static int append_bytes(uint8_t **output, size_t *size, size_t *capacity,
const unsigned char *bytes, size_t byte_count) {
if (byte_count == 0) return TRANSUI_MP3_OK;
if (*size > SIZE_MAX - byte_count) return TRANSUI_MP3_OUT_OF_MEMORY;
const size_t required = *size + byte_count;
if (required > *capacity) {
size_t next_capacity = *capacity;
while (next_capacity < required) {
if (next_capacity > SIZE_MAX / 2) {
next_capacity = required;
break;
}
next_capacity *= 2;
}
uint8_t *resized = realloc(*output, next_capacity);
if (!resized) return TRANSUI_MP3_OUT_OF_MEMORY;
*output = resized;
*capacity = next_capacity;
}
memcpy(*output + *size, bytes, byte_count);
*size = required;
return TRANSUI_MP3_OK;
}
int transui_mp3_encode_mono_16k_32kbps(const uint8_t *pcm_le,
size_t pcm_byte_count,
uint8_t **output,
size_t *output_size) {
if (!output || !output_size) return TRANSUI_MP3_INVALID_INPUT;
*output = NULL;
*output_size = 0;
if (!pcm_le || pcm_byte_count < 2 || pcm_byte_count % 2 != 0) {
return TRANSUI_MP3_INVALID_INPUT;
}
if (pcm_byte_count / 8 > SIZE_MAX - 8192) {
return TRANSUI_MP3_OUT_OF_MEMORY;
}
shine_config_t config;
config.wave.channels = PCM_MONO;
config.wave.samplerate = 16000;
shine_set_config_mpeg_defaults(&config.mpeg);
config.mpeg.mode = MONO;
config.mpeg.bitr = 32;
shine_t encoder = shine_initialise(&config);
if (!encoder) return TRANSUI_MP3_ENCODER_ERROR;
const int samples_per_pass = shine_samples_per_pass(encoder);
if (samples_per_pass <= 0) {
shine_close(encoder);
return TRANSUI_MP3_ENCODER_ERROR;
}
int16_t *frame = calloc((size_t)samples_per_pass, sizeof(int16_t));
size_t capacity = pcm_byte_count / 8 + 8192;
uint8_t *encoded = malloc(capacity);
if (!frame || !encoded) {
free(frame);
free(encoded);
shine_close(encoder);
return TRANSUI_MP3_OUT_OF_MEMORY;
}
const size_t sample_count = pcm_byte_count / 2;
size_t sample_offset = 0;
size_t encoded_size = 0;
int result = TRANSUI_MP3_OK;
while (sample_offset < sample_count) {
const size_t remaining = sample_count - sample_offset;
const size_t frame_samples =
remaining < (size_t)samples_per_pass ? remaining
: (size_t)samples_per_pass;
memset(frame, 0, (size_t)samples_per_pass * sizeof(int16_t));
for (size_t index = 0; index < frame_samples; ++index) {
const size_t byte_offset = (sample_offset + index) * 2;
frame[index] = (int16_t)((uint16_t)pcm_le[byte_offset] |
((uint16_t)pcm_le[byte_offset + 1] << 8));
}
int written = 0;
unsigned char *bytes =
shine_encode_buffer_interleaved(encoder, frame, &written);
if (written < 0 || (!bytes && written > 0)) {
result = TRANSUI_MP3_ENCODER_ERROR;
break;
}
result = append_bytes(&encoded, &encoded_size, &capacity, bytes,
(size_t)written);
if (result != TRANSUI_MP3_OK) break;
sample_offset += frame_samples;
}
if (result == TRANSUI_MP3_OK) {
int written = 0;
unsigned char *bytes = shine_flush(encoder, &written);
if (written < 0 || (!bytes && written > 0)) {
result = TRANSUI_MP3_ENCODER_ERROR;
} else {
result = append_bytes(&encoded, &encoded_size, &capacity, bytes,
(size_t)written);
}
}
free(frame);
shine_close(encoder);
if (result != TRANSUI_MP3_OK || encoded_size == 0) {
free(encoded);
return result == TRANSUI_MP3_OK ? TRANSUI_MP3_ENCODER_ERROR : result;
}
*output = encoded;
*output_size = encoded_size;
return TRANSUI_MP3_OK;
}
void transui_mp3_free(void *memory) { free(memory); }
+29
View File
@@ -0,0 +1,29 @@
#ifndef TRANSUI_MP3_H
#define TRANSUI_MP3_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
enum {
TRANSUI_MP3_OK = 0,
TRANSUI_MP3_INVALID_INPUT = 1,
TRANSUI_MP3_ENCODER_ERROR = 2,
TRANSUI_MP3_OUT_OF_MEMORY = 3,
};
int transui_mp3_encode_mono_16k_32kbps(const uint8_t *pcm_le,
size_t pcm_byte_count,
uint8_t **output,
size_t *output_size);
void transui_mp3_free(void *memory);
#ifdef __cplusplus
}
#endif
#endif