Compare commits
4
Commits
android-ui
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e7f32e0a | ||
|
|
136bf7cfa3 | ||
|
|
d63e8dd703 | ||
|
|
ca1437a4fa |
No files matched your search
@@ -1,8 +1,6 @@
|
|||||||
name: Android
|
name: Android
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -22,16 +20,11 @@ jobs:
|
|||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
# Gitea's act resolver cannot peel the annotated v4 tag alias.
|
# Gitea's act resolver cannot peel the annotated v4 tag alias.
|
||||||
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
- uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
||||||
with:
|
|
||||||
cache-read-only: ${{ gitea.event_name == 'pull_request' }}
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
|
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
|
||||||
- name: Lint and test
|
- name: Lint and test
|
||||||
working-directory: android
|
working-directory: android
|
||||||
run: ./gradlew --no-daemon lintDebug testDebugUnitTest
|
run: ./gradlew --no-daemon lintDebug testDebugUnitTest
|
||||||
- name: Build debug APK
|
|
||||||
working-directory: android
|
|
||||||
run: ./gradlew --no-daemon assembleDebug
|
|
||||||
- name: Build signed release APK
|
- name: Build signed release APK
|
||||||
working-directory: android
|
working-directory: android
|
||||||
env:
|
env:
|
||||||
@@ -52,12 +45,13 @@ jobs:
|
|||||||
working-directory: android
|
working-directory: android
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../artifacts
|
mkdir -p ../artifacts
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk ../artifacts/transui-android-debug.apk
|
|
||||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
||||||
cp app/build/outputs/apk/release/app-release.apk ../artifacts/transui-android-release.apk
|
cp app/build/outputs/apk/release/app-release.apk ../artifacts/transui-android-release.apk
|
||||||
|
cd ../artifacts
|
||||||
|
sha256sum ./*.apk > SHA256SUMS-android
|
||||||
|
else
|
||||||
|
echo "No release APK to publish"
|
||||||
fi
|
fi
|
||||||
cd ../artifacts
|
|
||||||
sha256sum ./*.apk > SHA256SUMS-android
|
|
||||||
- name: Attach APKs to the Gitea release
|
- name: Attach APKs to the Gitea release
|
||||||
if: gitea.event_name == 'release'
|
if: gitea.event_name == 'release'
|
||||||
env:
|
env:
|
||||||
@@ -72,4 +66,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: transui-android
|
name: transui-android
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: warn
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
/android/build/
|
/android/build/
|
||||||
/android/local.properties
|
/android/local.properties
|
||||||
/android/app/build/
|
/android/app/build/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Transui
|
# Transui
|
||||||
|
|
||||||
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp
|
A compact transcription tool for Linux, macOS, and Android. Linux and macOS
|
||||||
`/v1/audio/transcriptions` endpoints.
|
support OpenAI-compatible hosted endpoints with an optional llama.cpp local
|
||||||
|
fallback. Android provides remote-only speech recognition and voice input.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -13,19 +14,38 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \
|
|||||||
libjson-glib-dev wtype xdotool
|
libjson-glib-dev wtype xdotool
|
||||||
```
|
```
|
||||||
|
|
||||||
`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so
|
On macOS, install Xcode 16 and XcodeGen.
|
||||||
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
|
## Build
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cmake -S . -B build
|
cmake -S . -B build
|
||||||
cmake --build build
|
cmake --build build
|
||||||
sudo cmake --install build
|
sudo cmake --install build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -33,15 +53,3 @@ transui settings # configure endpoints, prompt, and destination
|
|||||||
transui record # open the record window
|
transui record # open the record window
|
||||||
transui # same as record
|
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.
|
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
package io.github.transui.android;
|
package io.github.transui.android;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.widget.ImageButton;
|
import android.widget.ImageButton;
|
||||||
|
|
||||||
public final class PushToTalkButton extends ImageButton {
|
public final class PushToTalkButton extends ImageButton {
|
||||||
|
enum IconMode {
|
||||||
|
APPS,
|
||||||
|
STOP_CIRCLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Paint iconPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private Runnable pressedAction;
|
private Runnable pressedAction;
|
||||||
private Runnable releasedAction;
|
private Runnable releasedAction;
|
||||||
private boolean trackingPress;
|
private boolean trackingPress;
|
||||||
|
private IconMode iconMode = IconMode.STOP_CIRCLE;
|
||||||
|
|
||||||
public PushToTalkButton(Context context, AttributeSet attributes) {
|
public PushToTalkButton(Context context, AttributeSet attributes) {
|
||||||
super(context, attributes);
|
super(context, attributes);
|
||||||
|
iconPaint.setColor(context.getColor(R.color.on_signal));
|
||||||
|
setImageDrawable(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setIconMode(IconMode iconMode) {
|
||||||
|
this.iconMode = iconMode;
|
||||||
|
setImageDrawable(null);
|
||||||
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
|
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
|
||||||
@@ -55,4 +72,48 @@ public final class PushToTalkButton extends ImageButton {
|
|||||||
public boolean performClick() {
|
public boolean performClick() {
|
||||||
return super.performClick();
|
return super.performClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
|
||||||
|
float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
|
||||||
|
float size = Math.min(contentWidth, contentHeight);
|
||||||
|
float centerX = getWidth() / 2f;
|
||||||
|
float centerY = getHeight() / 2f;
|
||||||
|
if (iconMode == IconMode.APPS) {
|
||||||
|
drawAppsIcon(canvas, centerX, centerY, size);
|
||||||
|
} else {
|
||||||
|
drawStopCircleIcon(canvas, centerX, centerY, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawAppsIcon(Canvas canvas, float centerX, float centerY, float size) {
|
||||||
|
iconPaint.setStyle(Paint.Style.FILL);
|
||||||
|
float spacing = size * 0.27f;
|
||||||
|
float radius = size * 0.09f;
|
||||||
|
for (int row = -1; row <= 1; row++) {
|
||||||
|
for (int column = -1; column <= 1; column++) {
|
||||||
|
canvas.drawCircle(
|
||||||
|
centerX + column * spacing,
|
||||||
|
centerY + row * spacing,
|
||||||
|
radius,
|
||||||
|
iconPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawStopCircleIcon(Canvas canvas, float centerX, float centerY, float size) {
|
||||||
|
iconPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
iconPaint.setStrokeWidth(size * 0.12f);
|
||||||
|
canvas.drawCircle(centerX, centerY, size * 0.42f, iconPaint);
|
||||||
|
iconPaint.setStyle(Paint.Style.FILL);
|
||||||
|
float squareHalfSize = size * 0.17f;
|
||||||
|
canvas.drawRect(
|
||||||
|
centerX - squareHalfSize,
|
||||||
|
centerY - squareHalfSize,
|
||||||
|
centerX + squareHalfSize,
|
||||||
|
centerY + squareHalfSize,
|
||||||
|
iconPaint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -80,25 +80,29 @@ public final class SpeechRecognitionActivity extends Activity {
|
|||||||
|
|
||||||
private void configureRecordButton() {
|
private void configureRecordButton() {
|
||||||
record.setAlpha(1);
|
record.setAlpha(1);
|
||||||
resizeRecordButton(config.pushToTalk ? 80 : 64, config.pushToTalk ? 23 : 19);
|
|
||||||
if (config.pushToTalk) {
|
if (config.pushToTalk) {
|
||||||
record.setImageResource(R.drawable.ic_mic);
|
record.setBackgroundResource(R.drawable.voice_pill_button);
|
||||||
|
resizeRecordButton(132, 64, 16);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.APPS);
|
||||||
record.setContentDescription(getString(R.string.hold_to_talk));
|
record.setContentDescription(getString(R.string.hold_to_talk));
|
||||||
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
} else {
|
} else {
|
||||||
record.setImageResource(R.drawable.ic_stop);
|
record.setBackgroundResource(R.drawable.voice_primary_button);
|
||||||
|
resizeRecordButton(64, 64, 19);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
record.setContentDescription(getString(R.string.stop));
|
record.setContentDescription(getString(R.string.stop));
|
||||||
record.setOnClickListener(view -> stopRecording());
|
record.setOnClickListener(view -> stopRecording());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resizeRecordButton(int sizeDp, int paddingDp) {
|
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
|
||||||
float density = getResources().getDisplayMetrics().density;
|
float density = getResources().getDisplayMetrics().density;
|
||||||
int size = Math.round(sizeDp * density);
|
int width = Math.round(widthDp * density);
|
||||||
|
int height = Math.round(heightDp * density);
|
||||||
int padding = Math.round(paddingDp * density);
|
int padding = Math.round(paddingDp * density);
|
||||||
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
||||||
parameters.width = size;
|
parameters.width = width;
|
||||||
parameters.height = size;
|
parameters.height = height;
|
||||||
record.setLayoutParams(parameters);
|
record.setLayoutParams(parameters);
|
||||||
record.setPadding(padding, padding, padding, padding);
|
record.setPadding(padding, padding, padding, padding);
|
||||||
}
|
}
|
||||||
@@ -194,7 +198,9 @@ public final class SpeechRecognitionActivity extends Activity {
|
|||||||
public void onRecording() {
|
public void onRecording() {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
status.setText(R.string.listening);
|
status.setText(R.string.listening);
|
||||||
if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
|
if (!config.pushToTalk) {
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
}
|
||||||
recordingStartedAt = SystemClock.elapsedRealtime();
|
recordingStartedAt = SystemClock.elapsedRealtime();
|
||||||
elapsed.setText(R.string.elapsed_zero);
|
elapsed.setText(R.string.elapsed_zero);
|
||||||
handler.post(updateElapsed);
|
handler.post(updateElapsed);
|
||||||
|
|||||||
@@ -109,25 +109,29 @@ public final class TransuiInputMethodService extends InputMethodService {
|
|||||||
record.setAlpha(1);
|
record.setAlpha(1);
|
||||||
record.setOnClickListener(null);
|
record.setOnClickListener(null);
|
||||||
record.setPushToTalkActions(null, null);
|
record.setPushToTalkActions(null, null);
|
||||||
resizeRecordButton(config.pushToTalk ? 76 : 64, config.pushToTalk ? 22 : 19);
|
|
||||||
if (config.pushToTalk) {
|
if (config.pushToTalk) {
|
||||||
record.setImageResource(R.drawable.ic_mic);
|
record.setBackgroundResource(R.drawable.voice_pill_button);
|
||||||
|
resizeRecordButton(132, 64, 16);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.APPS);
|
||||||
record.setContentDescription(getString(R.string.hold_to_talk));
|
record.setContentDescription(getString(R.string.hold_to_talk));
|
||||||
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
record.setPushToTalkActions(this::startRecording, this::stopRecording);
|
||||||
} else {
|
} else {
|
||||||
record.setImageResource(R.drawable.ic_stop);
|
record.setBackgroundResource(R.drawable.voice_primary_button);
|
||||||
|
resizeRecordButton(64, 64, 19);
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
record.setContentDescription(getString(R.string.stop));
|
record.setContentDescription(getString(R.string.stop));
|
||||||
record.setOnClickListener(view -> stopRecording());
|
record.setOnClickListener(view -> stopRecording());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resizeRecordButton(int sizeDp, int paddingDp) {
|
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
|
||||||
float density = getResources().getDisplayMetrics().density;
|
float density = getResources().getDisplayMetrics().density;
|
||||||
int size = Math.round(sizeDp * density);
|
int width = Math.round(widthDp * density);
|
||||||
|
int height = Math.round(heightDp * density);
|
||||||
int padding = Math.round(paddingDp * density);
|
int padding = Math.round(paddingDp * density);
|
||||||
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
ViewGroup.LayoutParams parameters = record.getLayoutParams();
|
||||||
parameters.width = size;
|
parameters.width = width;
|
||||||
parameters.height = size;
|
parameters.height = height;
|
||||||
record.setLayoutParams(parameters);
|
record.setLayoutParams(parameters);
|
||||||
record.setPadding(padding, padding, padding, padding);
|
record.setPadding(padding, padding, padding, padding);
|
||||||
}
|
}
|
||||||
@@ -176,7 +180,9 @@ public final class TransuiInputMethodService extends InputMethodService {
|
|||||||
public void onRecording() {
|
public void onRecording() {
|
||||||
handler.post(() -> {
|
handler.post(() -> {
|
||||||
status.setText(R.string.listening);
|
status.setText(R.string.listening);
|
||||||
if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
|
if (!config.pushToTalk) {
|
||||||
|
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path android:fillColor="#FF000000" android:pathData="M6,6h12v12H6z" />
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="@color/ripple_on_signal">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/signal" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</ripple>
|
||||||
@@ -60,9 +60,7 @@
|
|||||||
android:layout_marginTop="14dp"
|
android:layout_marginTop="14dp"
|
||||||
android:background="@drawable/voice_primary_button"
|
android:background="@drawable/voice_primary_button"
|
||||||
android:contentDescription="@string/stop"
|
android:contentDescription="@string/stop"
|
||||||
android:padding="16dp"
|
android:padding="16dp" />
|
||||||
android:src="@drawable/ic_stop"
|
|
||||||
android:tint="@color/paper" />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/recognition_cancel"
|
android:id="@+id/recognition_cancel"
|
||||||
|
|||||||
@@ -75,8 +75,6 @@
|
|||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:background="@drawable/voice_primary_button"
|
android:background="@drawable/voice_primary_button"
|
||||||
android:contentDescription="@string/stop"
|
android:contentDescription="@string/stop"
|
||||||
android:padding="21dp"
|
android:padding="21dp" />
|
||||||
android:src="@drawable/ic_stop"
|
|
||||||
android:tint="@color/paper" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<color name="mist">#11191D</color>
|
<color name="mist">#11191D</color>
|
||||||
<color name="paper">#192329</color>
|
<color name="paper">#192329</color>
|
||||||
<color name="line">#34444B</color>
|
<color name="line">#34444B</color>
|
||||||
|
<color name="on_signal">#142027</color>
|
||||||
<color name="ripple_on_signal">#3DFFFFFF</color>
|
<color name="ripple_on_signal">#3DFFFFFF</color>
|
||||||
<color name="ripple_signal">#3342C9C1</color>
|
<color name="ripple_signal">#3342C9C1</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<color name="mist">#F2F6F7</color>
|
<color name="mist">#F2F6F7</color>
|
||||||
<color name="paper">#FFFFFF</color>
|
<color name="paper">#FFFFFF</color>
|
||||||
<color name="line">#D5E0E3</color>
|
<color name="line">#D5E0E3</color>
|
||||||
|
<color name="on_signal">#FFFFFF</color>
|
||||||
<color name="ripple_on_signal">#3DFFFFFF</color>
|
<color name="ripple_on_signal">#3DFFFFFF</color>
|
||||||
<color name="ripple_signal">#33008D87</color>
|
<color name="ripple_signal">#33008D87</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DerivedData/
|
||||||
|
Transui.xcodeproj/
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
||||||
Reference in new issue
Block a user