15 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
intelligence 136bf7cfa3 chore: README 2026-08-29 19:36:39 +02:00
intelligence d63e8dd703 chore: unslop 2026-08-29 19:25:52 +02:00
jourintownandintelligence ca1437a4fa Android ui upgrade (#1)
---------

Co-authored-by: intelligence <intelligence@debian.local>
Reviewed-on: #1
2026-08-29 19:22:18 +02:00
intelligence 357edf9a2d fix:android ui v3
Android / build-android (push) Successful in 2m31s
Android / build-android (release) Successful in 2m31s
Build / build-amd64 (release) Successful in 35s
Build / build-arm64 (release) Canceled after 0s
2026-08-29 16:45:21 +02:00
intelligence 8f021959ef fix:android ui v2
Android / build-android (push) Successful in 2m28s
2026-08-29 16:35:19 +02:00
intelligence e4cf707d0a fix:android-ui
Android / build-android (push) Successful in 2m30s
2026-08-29 16:08:08 +02:00
intelligence 066c7d758e fix:parser and ui
Android / build-android (push) Successful in 2m30s
2026-08-29 15:43:35 +02:00
intelligence 2b89bbd959 fix:android
Android / build-android (push) Successful in 2m46s
2026-08-29 15:16:21 +02:00
intelligence 15ab99c740 fix:building
Android / build-android (push) Successful in 5m7s
2026-08-29 14:56:28 +02:00
intelligence e3dd14cef6 feat:and
Android / build-android (push) Failing after 6s
2026-08-29 14:51:02 +02:00
intelligence 00a4c9c25a Feat: clean model name. 2026-08-29 14:04:33 +02:00
intelligence 789c8d4762 model name 2026-08-29 13:42:36 +02:00
83 changed files with 4799 additions and 35 deletions

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
name: Android
on:
release:
types: [published]
workflow_dispatch:
jobs:
build-android:
runs-on: debian-latest
permissions:
contents: read
releases: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- 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
working-directory: android
run: ./gradlew --no-daemon lintDebug testDebugUnitTest
- name: Build signed release APK
working-directory: android
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ORG_GRADLE_PROJECT_transuiStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_transuiKeyAlias: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_transuiKeyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then
echo "Release signing secrets are not configured; skipping release APK"
exit 0
fi
keystore="$RUNNER_TEMP/transui-release.jks"
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore"
export ORG_GRADLE_PROJECT_transuiKeystore="$keystore"
./gradlew --no-daemon assembleRelease
- name: Prepare APK artifacts
working-directory: android
run: |
mkdir -p ../artifacts
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
cd ../artifacts
sha256sum ./*.apk > SHA256SUMS-android
else
echo "No release APK to publish"
fi
- name: Attach APKs to the Gitea release
if: gitea.event_name == 'release'
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }}
run: scripts/publish-release-assets.sh
- name: Upload APKs
if: always()
uses: christopherhx/gitea-upload-artifact@v4
with:
name: transui-android
path: artifacts/
if-no-files-found: warn
+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
+5
View File
@@ -1,2 +1,7 @@
/build/
/artifacts/
/android/.gradle/
/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
+29
View File
@@ -0,0 +1,29 @@
# 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 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 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.
+32 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(transui VERSION 0.1.0 LANGUAGES CXX)
project(transui VERSION 0.1.1 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -11,7 +11,7 @@ pkg_check_modules(GSTREAMER REQUIRED IMPORTED_TARGET gstreamer-1.0)
pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1)
pkg_check_modules(JSON_GLIB REQUIRED IMPORTED_TARGET json-glib-1.0)
add_executable(transui src/main.cpp)
add_executable(transui src/main.cpp src/transcript_cleaner.cpp)
target_link_libraries(transui PRIVATE
PkgConfig::GTK3
PkgConfig::CURL
@@ -23,6 +23,36 @@ target_link_libraries(transui PRIVATE
install(TARGETS transui RUNTIME DESTINATION bin)
install(FILES data/io.github.transui.Transui.desktop DESTINATION share/applications)
include(CTest)
if(BUILD_TESTING)
add_executable(transcript_cleaner_test
tests/transcript_cleaner_test.cpp
src/transcript_cleaner.cpp
)
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")
set(CPACK_PACKAGE_NAME "transui")
set(CPACK_PACKAGE_CONTACT "Transui contributors")
+28 -18
View File
@@ -1,7 +1,8 @@
# Transui
A compact GTK3 desktop transcription tool for OpenAI-compatible and llama.cpp
`/v1/audio/transcriptions` endpoints.
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
@@ -13,19 +14,40 @@ sudo apt install cmake g++ pkg-config libgtk-3-dev libcurl4-openssl-dev \
libjson-glib-dev wtype xdotool
```
`wtype` is used on Wayland and `xdotool` on X11. They are runtime tools, so
you only need the one matching your session. API keys are saved in the
freedesktop Secret Service (GNOME Keyring, KWallet Secret Service, and so on),
not in the settings file.
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
```
### 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
./gradlew lintDebug lintRelease testDebugUnitTest
```
## Commands
```sh
@@ -33,15 +55,3 @@ transui settings # configure endpoints, prompt, and destination
transui record # open the record window
transui # same as record
```
Bind `transui record` to a shortcut in your desktop environment. Transui does
not register a global shortcut itself. Press the same shortcut again to stop
and transcribe the current recording. Audio is recorded as 16 kHz mono MP3 at
32 kbps to keep uploads small.
The **Hosted first, local fallback** setting tries the Hosted endpoint, then
the Local endpoint when the first request fails. With it disabled, Transui uses
the Local endpoint only.
Both a normal response (`{"text":"..."}`) and llama.cpp event response
(`{"type":"transcript.text.done","text":"..."}`) are accepted.
+65
View File
@@ -0,0 +1,65 @@
plugins {
id 'com.android.application'
}
android {
namespace 'io.github.transui.android'
compileSdk 35
defaultConfig {
applicationId 'io.github.transui.android'
minSdk 26
targetSdk 35
versionCode 2
versionName '0.1.1'
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
externalNativeBuild {
cmake {
arguments '-DANDROID_STL=none'
}
}
}
signingConfigs {
if (project.hasProperty('transuiKeystore')) {
release {
storeFile file(project.property('transuiKeystore'))
storePassword project.property('transuiStorePassword')
keyAlias project.property('transuiKeyAlias')
keyPassword project.property('transuiKeyPassword')
}
}
}
buildTypes {
release {
minifyEnabled false
if (project.hasProperty('transuiKeystore')) {
signingConfig signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lint {
abortOnError true
checkReleaseBuilds true
warningsAsErrors true
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
}
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".SpeechRecognitionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/RecognitionTheme">
<intent-filter>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".TransuiRecognitionService"
android:exported="true"
android:label="@string/provider_name"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.speech.RecognitionService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.speech"
android:resource="@xml/recognition_service" />
</service>
<service
android:name=".TransuiInputMethodService"
android:exported="true"
android:label="@string/voice_input"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/input_method" />
</service>
</application>
</manifest>
+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,133 @@
package io.github.transui.android;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
import java.util.Arrays;
public final class AmplitudeView extends View {
private static final int LEVEL_COUNT = 192;
private final float[] levels = new float[LEVEL_COUNT];
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint edgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path waveform = new Path();
private final LinearGradient fillGradient;
private final Matrix gradientMatrix = new Matrix();
private final float sampleSpacing;
private final float minimumHalfHeight;
private final int signalColor;
private float previousLevel;
public AmplitudeView(Context context, AttributeSet attributes) {
super(context, attributes);
float density = getResources().getDisplayMetrics().density;
sampleSpacing = 5 * density;
minimumHalfHeight = 1.5f * density;
signalColor = context.getColor(R.color.signal);
fillPaint.setStyle(Paint.Style.FILL);
int transparentSignal = signalColor & 0x00ffffff;
fillGradient = new LinearGradient(
0, 0, 0, 1,
new int[]{transparentSignal, signalColor, transparentSignal},
new float[]{0, 0.5f, 1}, Shader.TileMode.CLAMP);
fillPaint.setShader(fillGradient);
edgePaint.setColor(signalColor);
edgePaint.setAlpha(210);
edgePaint.setStyle(Paint.Style.STROKE);
edgePaint.setStrokeWidth(1.5f * density);
edgePaint.setStrokeJoin(Paint.Join.ROUND);
centerPaint.setColor(signalColor);
centerPaint.setAlpha(70);
centerPaint.setStrokeWidth(density);
}
void setAmplitude(float amplitude) {
float level = Math.max(0, Math.min(1, amplitude));
level = (float) Math.sqrt(level);
level = Math.max(level, previousLevel * 0.58f);
System.arraycopy(levels, 1, levels, 0, levels.length - 1);
levels[levels.length - 1] = level;
previousLevel = level;
invalidate();
}
void reset() {
Arrays.fill(levels, 0);
previousLevel = 0;
invalidate();
}
@Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, oldHeight);
float top = getPaddingTop();
float contentHeight = Math.max(1, height - getPaddingTop() - getPaddingBottom());
gradientMatrix.setScale(1, contentHeight);
gradientMatrix.postTranslate(0, top);
fillGradient.setLocalMatrix(gradientMatrix);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float availableWidth = Math.max(0, getWidth() - getPaddingLeft() - getPaddingRight());
int visibleCount = Math.min(LEVEL_COUNT,
Math.max(2, (int) (availableWidth / sampleSpacing) + 1));
float step = availableWidth / (visibleCount - 1);
float startX = getPaddingLeft();
float centerY = getHeight() / 2f;
float maximumHalfHeight = Math.max(minimumHalfHeight,
(getHeight() - getPaddingTop() - getPaddingBottom()) / 2f);
int firstLevel = LEVEL_COUNT - visibleCount;
canvas.drawLine(startX, centerY, startX + availableWidth, centerY, centerPaint);
waveform.reset();
float previousX = startX;
float previousY = upperY(firstLevel, centerY, maximumHalfHeight);
waveform.moveTo(previousX, previousY);
for (int index = 1; index < visibleCount; index++) {
float x = startX + index * step;
float y = upperY(firstLevel + index, centerY, maximumHalfHeight);
waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
previousX = x;
previousY = y;
}
waveform.lineTo(previousX, previousY);
previousY = lowerY(firstLevel + visibleCount - 1, centerY, maximumHalfHeight);
waveform.lineTo(previousX, previousY);
for (int index = visibleCount - 2; index >= 0; index--) {
float x = startX + index * step;
float y = lowerY(firstLevel + index, centerY, maximumHalfHeight);
waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
previousX = x;
previousY = y;
}
waveform.lineTo(previousX, previousY);
waveform.close();
canvas.drawPath(waveform, fillPaint);
canvas.drawPath(waveform, edgePaint);
}
private float upperY(int index, float centerY, float maximumHalfHeight) {
return centerY - minimumHalfHeight
- levels[index] * (maximumHalfHeight - minimumHalfHeight);
}
private float lowerY(int index, float centerY, float maximumHalfHeight) {
return centerY + minimumHalfHeight
+ levels[index] * (maximumHalfHeight - minimumHalfHeight);
}
}
@@ -0,0 +1,26 @@
package io.github.transui.android;
final class AppConfig {
final String endpoint;
final String model;
final String apiKey;
final String prompt;
final boolean pushToTalk;
AppConfig(
String endpoint,
String model,
String apiKey,
String prompt,
boolean pushToTalk) {
this.endpoint = endpoint;
this.model = model;
this.apiKey = apiKey;
this.prompt = prompt;
this.pushToTalk = pushToTalk;
}
boolean hasUsableEndpoint() {
return !endpoint.isEmpty();
}
}
@@ -0,0 +1,31 @@
package io.github.transui.android;
final class AudioLevelMeter {
private double noiseFloorDecibels = Double.NaN;
float measure(short[] samples, int count) {
double squaredTotal = 0;
double peak = 0;
for (int index = 0; index < count; index++) {
double normalized = samples[index] / (double) Short.MAX_VALUE;
squaredTotal += normalized * normalized;
peak = Math.max(peak, Math.abs(normalized));
}
double rootMeanSquare = Math.sqrt(squaredTotal / count);
double effectiveLevel = Math.max(rootMeanSquare, peak * 0.12);
if (effectiveLevel <= 0) return 0;
double decibels = 20 * Math.log10(effectiveLevel);
if (Double.isNaN(noiseFloorDecibels)) {
noiseFloorDecibels = decibels;
} else if (decibels < noiseFloorDecibels + 3) {
noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.05;
} else {
noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.001;
}
double relative = (decibels - noiseFloorDecibels - 1.5) / 22;
double calibratedFallback = ((decibels + 72) / 54) * 0.35;
return (float) Math.max(0, Math.min(1, Math.max(relative, calibratedFallback)));
}
}
@@ -0,0 +1,59 @@
package io.github.transui.android;
import android.content.Context;
import android.content.SharedPreferences;
import java.io.IOException;
import java.security.GeneralSecurityException;
final class ConfigRepository {
private static final String PREFS = "settings";
private final SharedPreferences preferences;
ConfigRepository(Context context) {
preferences = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
removeObsoleteLocalSettings();
}
AppConfig load() {
return new AppConfig(
preferences.getString("remote_endpoint", ""),
preferences.getString("remote_model", ""),
decryptOrEmpty(preferences.getString("remote_key", "")),
preferences.getString("prompt", ""),
preferences.getBoolean("push_to_talk", false));
}
void save(AppConfig config) throws GeneralSecurityException, IOException {
preferences.edit()
.putString("remote_endpoint", config.endpoint)
.putString("remote_model", config.model)
.putString("remote_key", SecretStore.encrypt(config.apiKey))
.putString("prompt", config.prompt)
.putBoolean("push_to_talk", config.pushToTalk)
.apply();
}
private void removeObsoleteLocalSettings() {
if (!preferences.contains("local_endpoint")
&& !preferences.contains("local_model")
&& !preferences.contains("local_key")
&& !preferences.contains("remote_first")) {
return;
}
preferences.edit()
.remove("local_endpoint")
.remove("local_model")
.remove("local_key")
.remove("remote_first")
.apply();
}
private static String decryptOrEmpty(String value) {
try {
return SecretStore.decrypt(value);
} catch (GeneralSecurityException | IOException | IllegalArgumentException ignored) {
return "";
}
}
}
@@ -0,0 +1,130 @@
package io.github.transui.android;
import android.Manifest;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.provider.Settings;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import java.io.IOException;
import java.security.GeneralSecurityException;
public final class MainActivity extends Activity {
private static final int MICROPHONE_REQUEST = 1;
private static final String ACTION_LANGUAGE_SETTINGS = "android.settings.LANGUAGE_SETTINGS";
private EditText endpoint;
private EditText model;
private EditText apiKey;
private EditText prompt;
private Switch pushToTalk;
private TextView providerStatus;
private Button microphone;
private ConfigRepository repository;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
repository = new ConfigRepository(this);
bindViews();
showConfig(repository.load());
findViewById(R.id.save).setOnClickListener(view -> saveConfig());
microphone.setOnClickListener(view -> requestMicrophone());
findViewById(R.id.language_settings).setOnClickListener(view -> openLanguageSettings());
}
@Override
protected void onResume() {
super.onResume();
updateStatus();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
super.onRequestPermissionsResult(requestCode, permissions, results);
if (requestCode == MICROPHONE_REQUEST) updateStatus();
}
private void bindViews() {
endpoint = findViewById(R.id.endpoint);
model = findViewById(R.id.model);
apiKey = findViewById(R.id.api_key);
prompt = findViewById(R.id.prompt);
pushToTalk = findViewById(R.id.push_to_talk);
providerStatus = findViewById(R.id.provider_status);
microphone = findViewById(R.id.microphone);
}
private void showConfig(AppConfig config) {
endpoint.setText(config.endpoint);
model.setText(config.model);
apiKey.setText(config.apiKey);
prompt.setText(config.prompt);
pushToTalk.setChecked(config.pushToTalk);
}
private boolean saveConfig() {
AppConfig config = new AppConfig(
value(endpoint).trim(),
value(model).trim(),
value(apiKey).trim(),
value(prompt),
pushToTalk.isChecked());
try {
repository.save(config);
Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
return true;
} catch (GeneralSecurityException | IOException error) {
Toast.makeText(this, R.string.settings_save_failed, Toast.LENGTH_LONG).show();
return false;
}
}
private void requestMicrophone() {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, MICROPHONE_REQUEST);
}
}
private void openLanguageSettings() {
try {
startActivity(new Intent(ACTION_LANGUAGE_SETTINGS));
return;
} catch (ActivityNotFoundException ignored) {
// Older Android builds expose the combined Languages and input page here.
}
try {
startActivity(new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS));
} catch (ActivityNotFoundException ignored) {
Toast.makeText(this, R.string.language_settings_unavailable, Toast.LENGTH_LONG).show();
}
}
private void updateStatus() {
ComponentName service = new ComponentName(this, TransuiRecognitionService.class);
String selected = Settings.Secure.getString(getContentResolver(), "voice_recognition_service");
ComponentName selectedService = selected == null ? null : ComponentName.unflattenFromString(selected);
providerStatus.setText(service.equals(selectedService)
? R.string.status_selected
: R.string.status_not_selected);
boolean allowed = checkSelfPermission(Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
microphone.setText(allowed ? R.string.microphone_allowed : R.string.allow_microphone);
microphone.setEnabled(!allowed);
}
private static String value(EditText field) {
return field.getText().toString();
}
}
@@ -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);
}
@@ -0,0 +1,119 @@
package io.github.transui.android;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.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 releasedAction;
private boolean trackingPress;
private IconMode iconMode = IconMode.STOP_CIRCLE;
public PushToTalkButton(Context context, AttributeSet 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) {
this.pressedAction = pressedAction;
this.releasedAction = releasedAction;
trackingPress = false;
setPressed(false);
setScaleX(1);
setScaleY(1);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (pressedAction == null || releasedAction == null) return super.onTouchEvent(event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
trackingPress = true;
pressedAction.run();
setPressed(true);
animate().scaleX(0.92f).scaleY(0.92f).setDuration(90).start();
return true;
}
if (event.getAction() == MotionEvent.ACTION_UP) {
if (trackingPress) releasedAction.run();
trackingPress = false;
setPressed(false);
animate().scaleX(1).scaleY(1).setDuration(120).start();
super.performClick();
return true;
}
if (event.getAction() == MotionEvent.ACTION_CANCEL) {
if (trackingPress) releasedAction.run();
trackingPress = false;
setPressed(false);
animate().scaleX(1).scaleY(1).setDuration(120).start();
return true;
}
return true;
}
@Override
public boolean 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);
}
}
@@ -0,0 +1,62 @@
package io.github.transui.android;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
final class SecretStore {
private static final String KEY_ALIAS = "transui_api_keys";
private static final String KEYSTORE = "AndroidKeyStore";
private SecretStore() {}
static String encrypt(String value) throws GeneralSecurityException, IOException {
if (value.isEmpty()) return "";
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey());
byte[] ciphertext = cipher.doFinal(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return Base64.encodeToString(cipher.getIV(), Base64.NO_WRAP)
+ "."
+ Base64.encodeToString(ciphertext, Base64.NO_WRAP);
}
static String decrypt(String value) throws GeneralSecurityException, IOException {
if (value == null || value.isEmpty()) return "";
String[] parts = value.split("\\.", 2);
if (parts.length != 2) throw new GeneralSecurityException("Invalid encrypted value");
byte[] iv = Base64.decode(parts[0], Base64.NO_WRAP);
byte[] ciphertext = Base64.decode(parts[1], Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), new GCMParameterSpec(128, iv));
byte[] plaintext = cipher.doFinal(ciphertext);
String result = new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
Arrays.fill(plaintext, (byte) 0);
return result;
}
private static SecretKey getOrCreateKey() throws GeneralSecurityException, IOException {
KeyStore keyStore = KeyStore.getInstance(KEYSTORE);
keyStore.load(null);
java.security.Key key = keyStore.getKey(KEY_ALIAS, null);
if (key instanceof SecretKey) return (SecretKey) key;
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE);
generator.init(new KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build());
return generator.generateKey();
}
}
@@ -0,0 +1,240 @@
package io.github.transui.android;
import android.Manifest;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Locale;
public final class SpeechRecognitionActivity extends Activity {
private static final int MICROPHONE_REQUEST = 2;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable updateElapsed = new Runnable() {
@Override
public void run() {
long seconds = (SystemClock.elapsedRealtime() - recordingStartedAt) / 1000;
elapsed.setText(String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60));
handler.postDelayed(this, 1000);
}
};
private TextView status;
private TextView elapsed;
private AmplitudeView amplitude;
private PushToTalkButton record;
private AppConfig config;
private VoiceRecordingController controller;
private long recordingStartedAt;
private boolean sessionActive;
private boolean completed;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setResult(RESULT_CANCELED);
setContentView(R.layout.recognition_panel);
status = findViewById(R.id.recognition_status);
elapsed = findViewById(R.id.recognition_elapsed);
amplitude = findViewById(R.id.recognition_amplitude);
record = findViewById(R.id.recognition_record);
findViewById(R.id.recognition_cancel).setOnClickListener(view -> cancelAndFinish());
config = new ConfigRepository(this).load();
if (!config.hasUsableEndpoint()) {
showUnavailable(R.string.configure_first);
return;
}
configureRecordButton();
ensurePermissionAndStart();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) {
super.onRequestPermissionsResult(requestCode, permissions, results);
if (requestCode != MICROPHONE_REQUEST) return;
if (results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED) {
prepareForInput();
} else {
showUnavailable(R.string.recording_failed);
}
}
@Override
protected void onDestroy() {
handler.removeCallbacks(updateElapsed);
if (!completed && controller != null) controller.cancel();
super.onDestroy();
}
private void configureRecordButton() {
record.setAlpha(1);
if (config.pushToTalk) {
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.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
record.setBackgroundResource(R.drawable.voice_primary_button);
resizeRecordButton(64, 64, 19);
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
float density = getResources().getDisplayMetrics().density;
int width = Math.round(widthDp * density);
int height = Math.round(heightDp * density);
int padding = Math.round(paddingDp * density);
ViewGroup.LayoutParams parameters = record.getLayoutParams();
parameters.width = width;
parameters.height = height;
record.setLayoutParams(parameters);
record.setPadding(padding, padding, padding, padding);
}
private void ensurePermissionAndStart() {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
prepareForInput();
} else {
requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO}, MICROPHONE_REQUEST);
}
}
private void prepareForInput() {
if (config.pushToTalk) {
status.setText(R.string.hold_to_talk);
} else {
startRecording();
}
}
private void startRecording() {
if (sessionActive || completed) return;
sessionActive = true;
controller = new VoiceRecordingController(this, config, new UiListener());
controller.start();
}
private void stopRecording() {
if (!sessionActive || controller == null) return;
record.setEnabled(false);
record.setAlpha(0.4f);
amplitude.reset();
controller.stop();
}
private void showUnavailable(int message) {
status.setText(message);
elapsed.setText("");
amplitude.reset();
record.setEnabled(false);
record.setAlpha(0.4f);
}
private void cancelAndFinish() {
completed = true;
if (controller != null) controller.cancel();
handler.removeCallbacks(updateElapsed);
setResult(RESULT_CANCELED);
finish();
}
private void finishWithResult(String text) {
completed = true;
handler.removeCallbacks(updateElapsed);
ArrayList<String> results = new ArrayList<>();
results.add(text);
Intent result = new Intent();
result.putStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS, results);
result.putExtra(RecognizerIntent.EXTRA_CONFIDENCE_SCORES, new float[]{-1.0f});
setResult(RESULT_OK, result);
sendPendingResult(result, results);
finish();
}
@SuppressWarnings("deprecation")
private void sendPendingResult(Intent result, ArrayList<String> results) {
PendingIntent pendingIntent;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
pendingIntent = getIntent().getParcelableExtra(
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, PendingIntent.class);
} else {
pendingIntent = getIntent().getParcelableExtra(
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT);
}
if (pendingIntent == null) return;
Bundle supplied = getIntent().getBundleExtra(
RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE);
if (supplied != null) {
supplied.putStringArrayList(RecognizerIntent.EXTRA_RESULTS, results);
supplied.putFloatArray(RecognizerIntent.EXTRA_CONFIDENCE_SCORES, new float[]{-1.0f});
result.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, supplied);
}
try {
pendingIntent.send(this, RESULT_OK, result);
} catch (PendingIntent.CanceledException ignored) {
// The requesting application has already gone away.
}
}
private final class UiListener implements VoiceRecordingController.Listener {
@Override
public void onRecording() {
runOnUiThread(() -> {
status.setText(R.string.listening);
if (!config.pushToTalk) {
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
}
recordingStartedAt = SystemClock.elapsedRealtime();
elapsed.setText(R.string.elapsed_zero);
handler.post(updateElapsed);
});
}
@Override
public void onAmplitude(float level) {
runOnUiThread(() -> amplitude.setAmplitude(level));
}
@Override
public void onTranscribing() {
runOnUiThread(() -> {
handler.removeCallbacks(updateElapsed);
status.setText(R.string.transcribing);
amplitude.reset();
record.setEnabled(false);
record.setAlpha(0.4f);
});
}
@Override
public void onResult(String text) {
runOnUiThread(() -> finishWithResult(text));
}
@Override
public void onError(int errorCode) {
runOnUiThread(() -> showUnavailable(
errorCode == SpeechRecognizer.ERROR_NETWORK
|| errorCode == SpeechRecognizer.ERROR_SERVER
? R.string.transcription_failed
: R.string.recording_failed));
}
}
}
@@ -0,0 +1,30 @@
package io.github.transui.android;
final class TranscriptCleaner {
private static final String QWEN_LANGUAGE_PREFIX = "language ";
private static final String[] QWEN_TEXT_MARKERS = {"<asr_text>", "<astext>"};
private TranscriptCleaner() {}
static String clean(String transcript) {
// Qwen3-ASR prefixes raw transcripts with the detected language and a text marker.
int prefix = 0;
if (!transcript.isEmpty() && transcript.charAt(0) == '\ufeff') prefix++;
while (prefix < transcript.length() && Character.isWhitespace(transcript.charAt(prefix))) {
prefix++;
}
if (!transcript.startsWith(QWEN_LANGUAGE_PREFIX, prefix)) return transcript;
int marker = -1;
String matchedMarker = null;
for (String candidate : QWEN_TEXT_MARKERS) {
int position = transcript.indexOf(candidate, prefix + QWEN_LANGUAGE_PREFIX.length());
if (position >= 0 && (marker < 0 || position < marker)) {
marker = position;
matchedMarker = candidate;
}
}
if (matchedMarker == null) return transcript;
return transcript.substring(marker + matchedMarker.length());
}
}
@@ -0,0 +1,147 @@
package io.github.transui.android;
import android.speech.SpeechRecognizer;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
final class TranscriptionClient {
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private volatile HttpURLConnection activeConnection;
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, mp3);
}
void cancel() {
HttpURLConnection connection = activeConnection;
if (connection != null) connection.disconnect();
}
private Result request(
String endpoint,
String model,
String key,
String prompt,
byte[] mp3) {
HttpURLConnection connection = null;
try {
String boundary = "transui-" + UUID.randomUUID();
byte[] requestBody = multipartBody(boundary, model, prompt, mp3);
connection = (HttpURLConnection) new URL(endpointUrl(endpoint)).openConnection();
activeConnection = connection;
connection.setConnectTimeout(12_000);
connection.setReadTimeout(300_000);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(requestBody.length);
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
connection.setRequestProperty("Accept", "application/json");
if (!key.isEmpty()) connection.setRequestProperty("Authorization", "Bearer " + key);
try (java.io.OutputStream output = connection.getOutputStream()) {
output.write(requestBody);
}
int status = connection.getResponseCode();
InputStream stream = status >= 200 && status < 300
? connection.getInputStream()
: connection.getErrorStream();
String response = stream == null ? "" : readResponse(stream);
if (status < 200 || status >= 300) {
return Result.error("Server returned HTTP " + status, SpeechRecognizer.ERROR_SERVER);
}
JSONObject object = new JSONObject(response);
if (!object.has("text") || object.isNull("text")) {
return Result.error("Response does not contain a text field", SpeechRecognizer.ERROR_SERVER);
}
return Result.success(TranscriptCleaner.clean(object.getString("text")));
} catch (IOException error) {
return Result.error("Network request failed", SpeechRecognizer.ERROR_NETWORK);
} catch (JSONException error) {
return Result.error("Server returned invalid JSON", SpeechRecognizer.ERROR_SERVER);
} finally {
if (connection != null) connection.disconnect();
if (activeConnection == connection) activeConnection = null;
}
}
private static String endpointUrl(String endpoint) {
String result = endpoint;
while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
if (result.contains("/v1/audio/transcriptions")) return result;
return result + "/v1/audio/transcriptions";
}
private static byte[] multipartBody(String boundary, String model, String prompt, byte[] mp3)
throws IOException {
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.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();
}
private static void writeField(DataOutputStream output, String boundary, String name, String value)
throws IOException {
output.writeBytes("--" + boundary + "\r\n");
output.writeBytes("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n");
output.write(value.getBytes(StandardCharsets.UTF_8));
output.writeBytes("\r\n");
}
private static String readResponse(InputStream stream) throws IOException {
try (BufferedInputStream input = new BufferedInputStream(stream);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int total = 0;
int count;
while ((count = input.read(buffer)) != -1) {
total += count;
if (total > MAX_RESPONSE_BYTES) throw new IOException("Response is too large");
output.write(buffer, 0, count);
}
return output.toString(StandardCharsets.UTF_8.name());
}
}
static final class Result {
final String text;
final String error;
final int errorCode;
private Result(String text, String error, int errorCode) {
this.text = text;
this.error = error;
this.errorCode = errorCode;
}
static Result success(String text) {
return new Result(text, null, 0);
}
static Result error(String message, int errorCode) {
return new Result(null, message, errorCode);
}
}
}
@@ -0,0 +1,226 @@
package io.github.transui.android;
import android.Manifest;
import android.content.pm.PackageManager;
import android.inputmethodservice.InputMethodService;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.speech.SpeechRecognizer;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.inputmethod.InputConnection;
import android.widget.FrameLayout;
import android.widget.TextView;
public final class TransuiInputMethodService extends InputMethodService {
private final Handler handler = new Handler(Looper.getMainLooper());
private TextView status;
private AmplitudeView amplitude;
private PushToTalkButton record;
private AppConfig config;
private VoiceRecordingController controller;
private boolean sessionActive;
@Override
public boolean onEvaluateFullscreenMode() {
// Voice input should stay attached to the calling app in landscape. Android's
// default extract editor is the large, blank text area shown above the IME.
return false;
}
@Override
public View onCreateInputView() {
FrameLayout parent = new FrameLayout(this);
View view = getLayoutInflater().inflate(R.layout.voice_input, parent, false);
status = view.findViewById(R.id.voice_status);
amplitude = view.findViewById(R.id.voice_amplitude);
record = view.findViewById(R.id.voice_record);
view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
applySystemBarInsets(view);
return view;
}
private void applySystemBarInsets(View inputView) {
int originalLeft = inputView.getPaddingLeft();
int originalTop = inputView.getPaddingTop();
int originalRight = inputView.getPaddingRight();
int originalBottom = inputView.getPaddingBottom();
inputView.setOnApplyWindowInsetsListener((view, insets) -> {
int left;
int right;
int bottom;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
android.graphics.Insets navigation = insets.getInsets(
WindowInsets.Type.navigationBars());
android.graphics.Insets gestures = insets.getInsets(
WindowInsets.Type.mandatorySystemGestures());
left = Math.max(navigation.left, gestures.left);
right = Math.max(navigation.right, gestures.right);
bottom = Math.max(navigation.bottom, gestures.bottom);
} else {
left = insets.getSystemWindowInsetLeft();
right = insets.getSystemWindowInsetRight();
bottom = insets.getSystemWindowInsetBottom();
}
view.setPadding(
originalLeft + left,
originalTop,
originalRight + right,
originalBottom + bottom);
return insets;
});
inputView.post(inputView::requestApplyInsets);
}
@Override
public void onStartInputView(android.view.inputmethod.EditorInfo info, boolean restarting) {
super.onStartInputView(info, restarting);
cancelSession();
config = new ConfigRepository(this).load();
configureRecordButton();
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
showUnavailable(R.string.recording_failed);
} else if (!config.hasUsableEndpoint()) {
showUnavailable(R.string.configure_first);
} else if (config.pushToTalk) {
status.setText(R.string.hold_to_talk);
} else {
startRecording();
}
}
@Override
public void onFinishInputView(boolean finishingInput) {
cancelSession();
super.onFinishInputView(finishingInput);
}
@Override
public void onDestroy() {
cancelSession();
super.onDestroy();
}
private void configureRecordButton() {
record.setEnabled(true);
record.setAlpha(1);
record.setOnClickListener(null);
record.setPushToTalkActions(null, null);
if (config.pushToTalk) {
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.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
record.setBackgroundResource(R.drawable.voice_primary_button);
resizeRecordButton(64, 64, 19);
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
private void resizeRecordButton(int widthDp, int heightDp, int paddingDp) {
float density = getResources().getDisplayMetrics().density;
int width = Math.round(widthDp * density);
int height = Math.round(heightDp * density);
int padding = Math.round(paddingDp * density);
ViewGroup.LayoutParams parameters = record.getLayoutParams();
parameters.width = width;
parameters.height = height;
record.setLayoutParams(parameters);
record.setPadding(padding, padding, padding, padding);
}
private void startRecording() {
if (sessionActive) return;
sessionActive = true;
controller = new VoiceRecordingController(this, config, new ImeListener());
controller.start();
}
private void stopRecording() {
if (!sessionActive || controller == null) return;
record.setEnabled(false);
record.setAlpha(0.4f);
amplitude.reset();
controller.stop();
}
private void cancelSession() {
handler.removeCallbacksAndMessages(null);
if (controller != null) controller.cancel();
controller = null;
sessionActive = false;
if (amplitude != null) amplitude.reset();
}
private void showUnavailable(int message) {
status.setText(message);
amplitude.reset();
record.setEnabled(false);
record.setAlpha(0.4f);
}
private void returnToKeyboard() {
cancelSession();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
switchToPreviousInputMethod();
} else {
requestHideSelf(0);
}
}
private final class ImeListener implements VoiceRecordingController.Listener {
@Override
public void onRecording() {
handler.post(() -> {
status.setText(R.string.listening);
if (!config.pushToTalk) {
record.setIconMode(PushToTalkButton.IconMode.STOP_CIRCLE);
}
});
}
@Override
public void onAmplitude(float level) {
handler.post(() -> amplitude.setAmplitude(level));
}
@Override
public void onTranscribing() {
handler.post(() -> {
status.setText(R.string.transcribing);
amplitude.reset();
record.setEnabled(false);
record.setAlpha(0.4f);
});
}
@Override
public void onResult(String text) {
handler.post(() -> {
InputConnection connection = getCurrentInputConnection();
if (connection == null) {
showUnavailable(R.string.transcription_failed);
return;
}
connection.commitText(text, 1);
returnToKeyboard();
});
}
@Override
public void onError(int errorCode) {
handler.post(() -> showUnavailable(
errorCode == SpeechRecognizer.ERROR_NETWORK
|| errorCode == SpeechRecognizer.ERROR_SERVER
? R.string.transcription_failed
: R.string.recording_failed));
}
}
}
@@ -0,0 +1,292 @@
package io.github.transui.android;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.SystemClock;
import android.speech.RecognitionService;
import android.speech.RecognitionSupport;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public final class TransuiRecognitionService extends RecognitionService {
private static final String TAG = "TransuiRecognition";
private static final int SAMPLE_RATE = 16_000;
private static final long MAX_RECORDING_MS = 5 * 60_000;
private final Object sessionLock = new Object();
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private Session activeSession;
@Override
protected void onStartListening(Intent recognizerIntent, Callback callback) {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
sendError(callback, SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
return;
}
AppConfig config = new ConfigRepository(this).load();
if (!config.hasUsableEndpoint()) {
sendError(callback, SpeechRecognizer.ERROR_CLIENT);
return;
}
synchronized (sessionLock) {
if (activeSession != null) {
sendError(callback, SpeechRecognizer.ERROR_RECOGNIZER_BUSY);
return;
}
activeSession = new Session(callback, config);
executor.execute(activeSession);
}
}
@Override
protected void onStopListening(Callback callback) {
Session session;
synchronized (sessionLock) {
session = activeSession;
}
if (session != null) session.stop();
}
@Override
protected void onCancel(Callback callback) {
Session session;
synchronized (sessionLock) {
session = activeSession;
}
if (session != null) session.cancel();
}
@Override
@SuppressLint("NewApi")
public void onCheckRecognitionSupport(Intent recognizerIntent, SupportCallback callback) {
String language = recognizerIntent.getStringExtra(RecognizerIntent.EXTRA_LANGUAGE);
if (language == null || language.isEmpty()) language = Locale.getDefault().toLanguageTag();
callback.onSupportResult(new RecognitionSupport.Builder().addOnlineLanguage(language).build());
}
@Override
public void onDestroy() {
Session session;
synchronized (sessionLock) {
session = activeSession;
}
if (session != null) session.cancel();
executor.shutdownNow();
super.onDestroy();
}
private void clearSession(Session session) {
synchronized (sessionLock) {
if (activeSession == session) activeSession = null;
}
}
private static void sendError(Callback callback, int error) {
try {
callback.error(error);
} catch (RemoteException ignored) {
// The caller has already gone away.
}
}
private final class Session implements Runnable {
private final Callback callback;
private final AppConfig config;
private final AtomicBoolean stopRequested = new AtomicBoolean();
private final AtomicBoolean cancelled = new AtomicBoolean();
private final TranscriptionClient client = new TranscriptionClient();
Session(Callback callback, AppConfig config) {
this.callback = callback;
this.config = config;
}
@Override
public void run() {
try {
byte[] pcm = captureAudio();
if (pcm == null || cancelled.get()) return;
sendEndOfSpeech();
if (cancelled.get()) return;
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);
sendError(callback, result.errorCode);
return;
}
sendResults(result.text);
} finally {
clearSession(this);
}
}
void stop() {
stopRequested.set(true);
}
void cancel() {
cancelled.set(true);
stopRequested.set(true);
client.cancel();
}
private byte[] captureAudio() {
int minimum = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
if (minimum <= 0) {
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return null;
}
int bufferBytes = Math.max(minimum, 4096);
AudioRecord recorder;
try {
recorder = new AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(new AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build())
.setBufferSizeInBytes(bufferBytes)
.build();
} catch (IllegalArgumentException | SecurityException error) {
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return null;
}
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
recorder.release();
sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return null;
}
ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
short[] samples = new short[bufferBytes / 2];
boolean beginningSent = false;
long startedAt = SystemClock.elapsedRealtime();
try {
recorder.startRecording();
sendReady();
while (!cancelled.get()) {
int count = recorder.read(samples, 0, samples.length, AudioRecord.READ_BLOCKING);
if (count <= 0) {
if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return null;
}
appendSamples(pcm, samples, count);
sendRms(rmsDb(samples, count));
if (!beginningSent) {
beginningSent = true;
sendBeginningOfSpeech();
}
if (stopRequested.get()) break;
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
}
} catch (IllegalStateException | SecurityException error) {
if (!cancelled.get()) sendError(callback, SpeechRecognizer.ERROR_AUDIO);
return null;
} finally {
try {
recorder.stop();
} catch (IllegalStateException ignored) {
// The recorder did not reach its recording state.
}
recorder.release();
}
if (cancelled.get()) return null;
if (pcm.size() == 0) {
sendError(callback, SpeechRecognizer.ERROR_NO_MATCH);
return null;
}
return pcm.toByteArray();
}
private void sendReady() {
try {
callback.readyForSpeech(new Bundle());
} catch (RemoteException error) {
cancel();
}
}
private void sendBeginningOfSpeech() {
try {
callback.beginningOfSpeech();
} catch (RemoteException error) {
cancel();
}
}
private void sendRms(float rms) {
try {
callback.rmsChanged(rms);
} catch (RemoteException error) {
cancel();
}
}
private void sendEndOfSpeech() {
try {
callback.endOfSpeech();
} catch (RemoteException error) {
cancel();
}
}
private void sendResults(String text) {
Bundle bundle = new Bundle();
ArrayList<String> results = new ArrayList<>();
results.add(text);
bundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results);
bundle.putFloatArray(SpeechRecognizer.CONFIDENCE_SCORES, new float[]{-1.0f});
try {
callback.results(bundle);
} catch (RemoteException ignored) {
// The caller has already gone away.
}
}
}
private static void appendSamples(ByteArrayOutputStream output, short[] samples, int count) {
for (int index = 0; index < count; index++) {
int sample = samples[index];
output.write(sample & 0xff);
output.write((sample >>> 8) & 0xff);
}
}
private static float rmsDb(short[] samples, int count) {
double sum = 0;
for (int index = 0; index < count; index++) {
double normalized = samples[index] / 32768.0;
sum += normalized * normalized;
}
double rms = Math.sqrt(sum / count);
return rms <= 0 ? -100.0f : (float) (20.0 * Math.log10(rms));
}
}
@@ -0,0 +1,170 @@
package io.github.transui.android;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.speech.SpeechRecognizer;
import android.os.SystemClock;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
final class VoiceRecordingController {
interface Listener {
void onRecording();
void onAmplitude(float amplitude);
void onTranscribing();
void onResult(String text);
void onError(int errorCode);
}
private static final int SAMPLE_RATE = 16_000;
private static final long MAX_RECORDING_MS = 5 * 60_000;
private final Context context;
private final AppConfig config;
private final Listener listener;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final AtomicBoolean stopRequested = new AtomicBoolean();
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicBoolean started = new AtomicBoolean();
private final TranscriptionClient client = new TranscriptionClient();
private final AudioLevelMeter levelMeter = new AudioLevelMeter();
VoiceRecordingController(Context context, AppConfig config, Listener listener) {
this.context = context.getApplicationContext();
this.config = config;
this.listener = listener;
}
void start() {
if (!started.compareAndSet(false, true)) return;
executor.execute(this::recordAndTranscribe);
}
void stop() {
stopRequested.set(true);
}
void cancel() {
cancelled.set(true);
stopRequested.set(true);
client.cancel();
}
private void recordAndTranscribe() {
try {
byte[] pcm = captureAudio();
if (pcm == null || cancelled.get()) return;
listener.onTranscribing();
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);
} else {
listener.onError(result.errorCode);
}
} finally {
executor.shutdown();
}
}
@SuppressLint("MissingPermission")
private byte[] captureAudio() {
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
listener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS);
return null;
}
int minimum = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
if (minimum <= 0) {
listener.onError(SpeechRecognizer.ERROR_AUDIO);
return null;
}
int bufferBytes = Math.max(minimum, 4096);
AudioRecord recorder;
try {
recorder = new AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(new AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build())
.setBufferSizeInBytes(bufferBytes)
.build();
} catch (IllegalArgumentException | SecurityException error) {
listener.onError(SpeechRecognizer.ERROR_AUDIO);
return null;
}
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
recorder.release();
listener.onError(SpeechRecognizer.ERROR_AUDIO);
return null;
}
ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
// Keep meter updates quick even when AudioRecord asks for a large internal buffer.
short[] samples = new short[Math.min(bufferBytes / 2, 1024)];
long startedAt = SystemClock.elapsedRealtime();
try {
recorder.startRecording();
listener.onRecording();
while (!cancelled.get()) {
int count = recorder.read(samples, 0, samples.length, AudioRecord.READ_BLOCKING);
if (count <= 0) {
if (!cancelled.get()) listener.onError(SpeechRecognizer.ERROR_AUDIO);
return null;
}
appendSamples(pcm, samples, count);
listener.onAmplitude(levelMeter.measure(samples, count));
if (stopRequested.get()) break;
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
}
} catch (IllegalStateException | SecurityException error) {
if (!cancelled.get()) listener.onError(SpeechRecognizer.ERROR_AUDIO);
return null;
} finally {
try {
recorder.stop();
} catch (IllegalStateException ignored) {
// The recorder did not reach its recording state.
}
recorder.release();
}
if (cancelled.get()) return null;
if (pcm.size() == 0) {
listener.onError(SpeechRecognizer.ERROR_NO_MATCH);
return null;
}
return pcm.toByteArray();
}
private static void appendSamples(ByteArrayOutputStream output, short[] samples, int count) {
for (int index = 0; index < count; index++) {
int sample = samples[index];
output.write(sample & 0xff);
output.write((sample >>> 8) & 0xff);
}
}
}
@@ -0,0 +1,10 @@
<?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="M20,5H4C2.89,5 2,5.89 2,7v10c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V7c0,-1.11 -0.89,-2 -2,-2zM11,8h2v2h-2V8zM11,11h2v2h-2v-2zM8,8h2v2H8V8zM8,11h2v2H8v-2zM7,13H5v-2h2v2zM7,10H5V8h2v2zM17,17H7v-2h10v2zM17,13h-3v-2h3v2zM17,10h-3V8h3v2zM19,13h-1v-2h1v2zM19,10h-1V8h1v2z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#008D87"
android:pathData="M54,4A50,50 0,1 0,54 104A50,50 0,1 0,54 4" />
<path
android:fillColor="#FFFFFF"
android:pathData="M25,49h8v10h-8zM38,38h8v32h-8zM51,27h8v54h-8zM64,38h8v32h-8zM77,49h8v10h-8z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/paper" />
<stroke android:width="1dp" android:color="@color/line" />
<corners android:radius="12dp" />
</shape>
@@ -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>
@@ -0,0 +1,9 @@
<?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="oval">
<solid android:color="@color/signal" />
</shape>
</item>
</ripple>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple_signal">
<item>
<shape android:shape="oval">
<solid android:color="@color/mist" />
</shape>
</item>
</ripple>
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="20dp"
android:paddingTop="28dp"
android:paddingEnd="20dp"
android:paddingBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/title"
android:textColor="@color/ink"
android:textSize="27sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:background="@drawable/panel"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:layout_width="5dp"
android:layout_height="54dp"
android:background="@color/signal" />
<TextView
android:id="@+id/provider_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textColor="@color/ink"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
style="@style/SectionLabel"
android:text="@string/server" />
<EditText
android:id="@+id/endpoint"
style="@style/Field"
android:hint="@string/endpoint"
android:importantForAutofill="no"
android:inputType="textUri" />
<EditText
android:id="@+id/model"
style="@style/Field"
android:hint="@string/model"
android:importantForAutofill="no"
android:inputType="text" />
<EditText
android:id="@+id/api_key"
style="@style/Field"
android:hint="@string/api_key"
android:importantForAutofill="no"
android:inputType="textPassword" />
<TextView
style="@style/SectionLabel"
android:text="@string/request" />
<EditText
android:id="@+id/prompt"
style="@style/Field"
android:hint="@string/prompt"
android:importantForAutofill="no"
android:inputType="textCapSentences|textMultiLine"
android:maxLines="3"
android:singleLine="false" />
<Switch
android:id="@+id/push_to_talk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/push_to_talk"
android:textColor="@color/ink" />
<Button
android:id="@+id/save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/save" />
<Button
android:id="@+id/microphone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/allow_microphone" />
<Button
android:id="@+id/language_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/open_language_settings" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/panel"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/voice_input"
android:textColor="@color/ink"
android:textSize="21sp"
android:textStyle="bold" />
<View
android:layout_width="48dp"
android:layout_height="4dp"
android:layout_marginTop="12dp"
android:background="@color/signal" />
<TextView
android:id="@+id/recognition_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:gravity="center"
android:text="@string/listening"
android:textColor="@color/ink"
android:textSize="18sp" />
<TextView
android:id="@+id/recognition_elapsed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:fontFamily="monospace"
android:gravity="center"
android:text="@string/elapsed_zero"
android:textColor="@color/slate"
android:textSize="14sp" />
<io.github.transui.android.AmplitudeView
android:id="@+id/recognition_amplitude"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginTop="12dp"
android:importantForAccessibility="no"
android:paddingStart="12dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp" />
<io.github.transui.android.PushToTalkButton
android:id="@+id/recognition_record"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="14dp"
android:background="@drawable/voice_primary_button"
android:contentDescription="@string/stop"
android:padding="16dp" />
<Button
android:id="@+id/recognition_cancel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/cancel" />
</LinearLayout>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/paper"
android:orientation="vertical"
tools:ignore="Overdraw">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/line" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="18dp"
android:paddingTop="10dp"
android:paddingEnd="18dp">
<View
android:layout_width="4dp"
android:layout_height="24dp"
android:background="@color/signal" />
<TextView
android:id="@+id/voice_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:text="@string/listening"
android:textColor="@color/ink"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
<io.github.transui.android.AmplitudeView
android:id="@+id/voice_amplitude"
android:layout_width="match_parent"
android:layout_height="54dp"
android:layout_marginStart="28dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="28dp"
android:importantForAccessibility="no"
android:paddingTop="5dp"
android:paddingBottom="5dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="82dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/voice_keyboard"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_gravity="start|center_vertical"
android:background="@drawable/voice_secondary_button"
android:contentDescription="@string/keyboard"
android:padding="13dp"
android:src="@drawable/ic_keyboard"
android:tint="@color/ink" />
<io.github.transui.android.PushToTalkButton
android:id="@+id/voice_record"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center"
android:background="@drawable/voice_primary_button"
android:contentDescription="@string/stop"
android:padding="21dp" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ink">#EAF1F2</color>
<color name="slate">#A8B7BC</color>
<color name="signal">#42C9C1</color>
<color name="mist">#11191D</color>
<color name="paper">#192329</color>
<color name="line">#34444B</color>
<color name="on_signal">#142027</color>
<color name="ripple_on_signal">#3DFFFFFF</color>
<color name="ripple_signal">#3342C9C1</color>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:statusBarColor">@color/mist</item>
<item name="android:navigationBarColor">@color/paper</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">@color/signal</item>
<item name="android:windowBackground">@color/mist</item>
</style>
<style name="RecognitionTheme" parent="android:style/Theme.Material.Dialog.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/signal</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="android:windowMinWidthMajor">82%</item>
<item name="android:windowMinWidthMinor">82%</item>
</style>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ink">#142027</color>
<color name="slate">#56676F</color>
<color name="signal">#008D87</color>
<color name="mist">#F2F6F7</color>
<color name="paper">#FFFFFF</color>
<color name="line">#D5E0E3</color>
<color name="on_signal">#FFFFFF</color>
<color name="ripple_on_signal">#3DFFFFFF</color>
<color name="ripple_signal">#33008D87</color>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Transui</string>
<string name="provider_name">Transui speech recognition</string>
<string name="title">Transui voice input</string>
<string name="status_selected">Selected as voice input</string>
<string name="status_not_selected">Select Transui as voice input</string>
<string name="server">SERVER</string>
<string name="request">REQUEST</string>
<string name="endpoint">Transcription endpoint</string>
<string name="model">Model</string>
<string name="api_key">API key</string>
<string name="prompt">Prompt</string>
<string name="push_to_talk">Push to talk</string>
<string name="save">Save settings</string>
<string name="open_language_settings">Open language settings</string>
<string name="allow_microphone">Allow microphone</string>
<string name="microphone_allowed">Microphone allowed</string>
<string name="settings_saved">Settings saved</string>
<string name="settings_save_failed">Could not save API keys</string>
<string name="language_settings_unavailable">Language settings are unavailable</string>
<string name="voice_input">Transui voice input</string>
<string name="listening">Listening</string>
<string name="hold_to_talk">Hold to talk</string>
<string name="transcribing">Transcribing</string>
<string name="stop">Stop</string>
<string name="cancel">Cancel</string>
<string name="keyboard">Keyboard</string>
<string name="recording_failed">Could not record</string>
<string name="transcription_failed">Could not transcribe</string>
<string name="configure_first">Configure a transcription endpoint first</string>
<string name="elapsed_zero">00:00</string>
</resources>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">@color/mist</item>
<item name="android:navigationBarColor">@color/paper</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">@color/signal</item>
<item name="android:windowBackground">@color/mist</item>
</style>
<style name="Field">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">4dp</item>
<item name="android:singleLine">true</item>
<item name="android:textColor">@color/ink</item>
<item name="android:textColorHint">@color/slate</item>
</style>
<style name="SectionLabel">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">22dp</item>
<item name="android:textColor">@color/signal</item>
<item name="android:textSize">12sp</item>
<item name="android:textStyle">bold</item>
<item name="android:letterSpacing">0.12</item>
</style>
<style name="RecognitionTheme" parent="android:style/Theme.Material.Light.Dialog.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/signal</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowCloseOnTouchOutside">false</item>
<item name="android:windowMinWidthMajor">82%</item>
<item name="android:windowMinWidthMinor">82%</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="io.github.transui.android.MainActivity"
android:supportsSwitchingToNextInputMethod="true">
<subtype
android:imeSubtypeLocale=""
android:imeSubtypeMode="voice"
android:isAuxiliary="true"
android:label="@string/voice_input"
android:overridesImplicitlyEnabledSubtype="true" />
</input-method>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<recognition-service xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="io.github.transui.android.MainActivity" />
@@ -0,0 +1,35 @@
package io.github.transui.android;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class AudioLevelMeterTest {
@Test
public void silenceStaysAtZero() {
AudioLevelMeter meter = new AudioLevelMeter();
assertEquals(0, meter.measure(new short[1024], 1024), 0);
}
@Test
public void speechRisesAboveQuietMicrophoneInput() {
AudioLevelMeter meter = new AudioLevelMeter();
short[] quiet = alternatingSamples(8);
short[] speech = alternatingSamples(1200);
float quietLevel = meter.measure(quiet, quiet.length);
float speechLevel = meter.measure(speech, speech.length);
assertTrue(speechLevel > quietLevel + 0.5f);
}
private static short[] alternatingSamples(int amplitude) {
short[] samples = new short[1024];
for (int index = 0; index < samples.length; index++) {
samples[index] = (short) (index % 2 == 0 ? amplitude : -amplitude);
}
return samples;
}
}
@@ -0,0 +1,34 @@
package io.github.transui.android;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public final class TranscriptCleanerTest {
@Test
public void removesObservedQwenLanguagePrefixes() {
assertEquals("What do you think?", TranscriptCleaner.clean(
"language English<astext>What do you think?"));
assertEquals("\u6765\u8ba9\u4f60\u53d1\u73b0\u4ed6\u4eec\u53ef\u80fd\u7684\u4e0d\u7684\u6807\u7b7e\u3002", TranscriptCleaner.clean(
"language Chinese<astext>\u6765\u8ba9\u4f60\u53d1\u73b0\u4ed6\u4eec\u53ef\u80fd\u7684\u4e0d\u7684\u6807\u7b7e\u3002"));
assertEquals("Ich habe keine Idee.", TranscriptCleaner.clean(
"language German<asr_text>Ich habe keine Idee."));
assertEquals("\u3042\u308a\u304c\u3068\u3046\u3054\u3056\u3044\u307e\u3059\u3002", TranscriptCleaner.clean(
"language Japanese<asr_text>\u3042\u308a\u304c\u3068\u3046\u3054\u3056\u3044\u307e\u3059\u3002"));
}
@Test
public void removesPrefixAfterLeadingWhitespace() {
assertEquals("Hello", TranscriptCleaner.clean(" \n\tlanguage English<asr_text>Hello"));
}
@Test
public void keepsOrdinaryTranscript() {
assertEquals("Language matters.", TranscriptCleaner.clean("Language matters."));
}
@Test
public void keepsIncompleteQwenPrefix() {
assertEquals("language English", TranscriptCleaner.clean("language English"));
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.9.2' apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
org.gradle.parallel=false
android.useAndroidX=false
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TransuiAndroid"
include ':app'
+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
+1 -1
View File
@@ -35,7 +35,7 @@ python3 -c 'import json,sys; [print(a["name"]) for a in json.load(sys.stdin).get
< "$metadata" > "$existing_names"
shopt -s nullglob
assets=("$artifact_dir"/*.deb)
assets=("$artifact_dir"/*.deb "$artifact_dir"/*.apk)
checksums=("$artifact_dir"/SHA256SUMS*)
assets+=("${checksums[@]}")
((${#assets[@]} > 0)) || {
+38 -12
View File
@@ -15,6 +15,8 @@
#include <utility>
#include <vector>
#include "transcript_cleaner.h"
namespace {
constexpr const char* kAppId = "io.github.transui";
@@ -27,7 +29,9 @@ const SecretSchema kSecretSchema = {
struct Config {
std::string hosted_url;
std::string hosted_model;
std::string local_url = "http://localhost:8080";
std::string local_model;
std::string prompt;
bool hosted_first = true;
bool clipboard = false;
@@ -52,8 +56,10 @@ struct App {
struct SettingsFields {
GtkWidget* hosted_url;
GtkWidget* hosted_model;
GtkWidget* hosted_key;
GtkWidget* local_url;
GtkWidget* local_model;
GtkWidget* local_key;
GtkWidget* prompt;
GtkWidget* hosted_first;
@@ -126,8 +132,12 @@ void load_config(App* app) {
gchar* value = nullptr;
value = g_key_file_get_string(file, kConfigGroup, "hosted_url", nullptr);
if (value) { app->config.hosted_url = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "hosted_model", nullptr);
if (value) { app->config.hosted_model = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "local_url", nullptr);
if (value) { app->config.local_url = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "local_model", nullptr);
if (value) { app->config.local_model = value; g_free(value); }
value = g_key_file_get_string(file, kConfigGroup, "prompt", nullptr);
if (value) { app->config.prompt = value; g_free(value); }
if (g_key_file_has_key(file, kConfigGroup, "hosted_first", nullptr))
@@ -145,7 +155,9 @@ bool save_config(App* app, const Config& config, const std::string& hosted_key,
const std::string& local_key, std::string* err) {
GKeyFile* file = g_key_file_new();
g_key_file_set_string(file, kConfigGroup, "hosted_url", config.hosted_url.c_str());
g_key_file_set_string(file, kConfigGroup, "hosted_model", config.hosted_model.c_str());
g_key_file_set_string(file, kConfigGroup, "local_url", config.local_url.c_str());
g_key_file_set_string(file, kConfigGroup, "local_model", config.local_model.c_str());
g_key_file_set_string(file, kConfigGroup, "prompt", config.prompt.c_str());
g_key_file_set_boolean(file, kConfigGroup, "hosted_first", config.hosted_first);
g_key_file_set_boolean(file, kConfigGroup, "clipboard", config.clipboard);
@@ -190,7 +202,9 @@ void save_clicked(GtkButton*, gpointer data) {
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(fields->hosted_url), "app"));
Config config;
config.hosted_url = gtk_entry_get_text(GTK_ENTRY(fields->hosted_url));
config.hosted_model = gtk_entry_get_text(GTK_ENTRY(fields->hosted_model));
config.local_url = gtk_entry_get_text(GTK_ENTRY(fields->local_url));
config.local_model = gtk_entry_get_text(GTK_ENTRY(fields->local_model));
config.prompt = gtk_entry_get_text(GTK_ENTRY(fields->prompt));
config.hosted_first = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(fields->hosted_first));
config.clipboard = gtk_combo_box_get_active(GTK_COMBO_BOX(fields->destination)) == 1;
@@ -234,21 +248,25 @@ void open_settings(App* app) {
auto* fields = new SettingsFields();
fields->hosted_url = labelled(grid, "Hosted endpoint", gtk_entry_new(), 0);
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 1);
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 2);
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 3);
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 4);
fields->hosted_model = labelled(grid, "Hosted model", gtk_entry_new(), 1);
fields->hosted_key = labelled(grid, "Hosted API key", gtk_entry_new(), 2);
fields->local_url = labelled(grid, "Local endpoint", gtk_entry_new(), 3);
fields->local_model = labelled(grid, "Local model", gtk_entry_new(), 4);
fields->local_key = labelled(grid, "Local API key", gtk_entry_new(), 5);
fields->prompt = labelled(grid, "Prompt", gtk_entry_new(), 6);
fields->hosted_first = gtk_check_button_new_with_label("Hosted first, local fallback");
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 5, 1, 1);
gtk_grid_attach(GTK_GRID(grid), fields->hosted_first, 1, 7, 1, 1);
fields->destination = gtk_combo_box_text_new();
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Type at cursor");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(fields->destination), "Clipboard");
labelled(grid, "Output", fields->destination, 6);
labelled(grid, "Output", fields->destination, 8);
gtk_entry_set_visibility(GTK_ENTRY(fields->hosted_key), false);
gtk_entry_set_visibility(GTK_ENTRY(fields->local_key), false);
gtk_entry_set_text(GTK_ENTRY(fields->hosted_url), app->config.hosted_url.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->hosted_model), app->config.hosted_model.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->hosted_key), app->hosted_key.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->local_url), app->config.local_url.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->local_model), app->config.local_model.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->local_key), app->local_key.c_str());
gtk_entry_set_text(GTK_ENTRY(fields->prompt), app->config.prompt.c_str());
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(fields->hosted_first), app->config.hosted_first);
@@ -318,7 +336,8 @@ size_t curl_write(char* buffer, size_t size, size_t nmemb, void* data) {
}
bool transcription_request(const std::string& url, const std::string& key,
const std::string& path, const std::string& prompt,
const std::string& model, const std::string& path,
const std::string& prompt,
std::string* transcript, std::string* error) {
CURL* curl = curl_easy_init();
if (!curl) { *error = "Could not initialize HTTP client"; return false; }
@@ -327,6 +346,11 @@ bool transcription_request(const std::string& url, const std::string& key,
curl_mimepart* part = curl_mime_addpart(mime);
curl_mime_name(part, "response_format");
curl_mime_data(part, "json", CURL_ZERO_TERMINATED);
if (!model.empty()) {
part = curl_mime_addpart(mime);
curl_mime_name(part, "model");
curl_mime_data(part, model.c_str(), CURL_ZERO_TERMINATED);
}
part = curl_mime_addpart(mime);
curl_mime_name(part, "file");
curl_mime_filedata(part, path.c_str());
@@ -380,7 +404,7 @@ bool transcription_request(const std::string& url, const std::string& key,
g_object_unref(parser);
return false;
}
*transcript = json_object_get_string_member(object, "text");
*transcript = clean_transcript(json_object_get_string_member(object, "text"));
g_object_unref(parser);
return true;
}
@@ -452,15 +476,17 @@ gpointer job_thread(gpointer data) {
auto* job = static_cast<Job*>(data);
std::string hosted_error;
if (job->config.hosted_first && !job->config.hosted_url.empty()) {
job->ok = transcription_request(job->config.hosted_url, job->hosted_key, job->file,
job->config.prompt, &job->text, &hosted_error);
job->ok = transcription_request(job->config.hosted_url, job->hosted_key,
job->config.hosted_model, job->file, job->config.prompt,
&job->text, &hosted_error);
if (job->ok) {
g_main_context_invoke(nullptr, finish_job, job);
return nullptr;
}
}
job->ok = transcription_request(job->config.local_url, job->local_key, job->file,
job->config.prompt, &job->text, &job->error);
job->ok = transcription_request(job->config.local_url, job->local_key,
job->config.local_model, job->file, job->config.prompt,
&job->text, &job->error);
if (!job->ok && !hosted_error.empty()) job->error = "Hosted: " + hosted_error + "\nLocal: " + job->error;
g_main_context_invoke(nullptr, finish_job, job);
return nullptr;
+33
View File
@@ -0,0 +1,33 @@
#include "transcript_cleaner.h"
#include <cctype>
#include <string_view>
std::string clean_transcript(std::string transcript) {
// Qwen3-ASR prefixes raw transcripts with the detected language and a text marker.
constexpr std::string_view kLanguagePrefix = "language ";
constexpr std::string_view kMarkers[] = {"<asr_text>", "<astext>"};
size_t prefix = 0;
if (transcript.compare(0, 3, "\xef\xbb\xbf") == 0) prefix = 3;
while (prefix < transcript.size() &&
std::isspace(static_cast<unsigned char>(transcript[prefix]))) {
prefix++;
}
if (transcript.compare(prefix, kLanguagePrefix.size(), kLanguagePrefix) != 0) {
return transcript;
}
size_t marker_position = std::string::npos;
std::string_view matched_marker;
for (std::string_view marker : kMarkers) {
const size_t position = transcript.find(marker, prefix + kLanguagePrefix.size());
if (position != std::string::npos &&
(marker_position == std::string::npos || position < marker_position)) {
marker_position = position;
matched_marker = marker;
}
}
if (marker_position == std::string::npos) return transcript;
return transcript.substr(marker_position + matched_marker.size());
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <string>
std::string clean_transcript(std::string transcript);
+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;
}
+17
View File
@@ -0,0 +1,17 @@
#include "transcript_cleaner.h"
#include <cassert>
int main() {
assert(clean_transcript("language English<astext>What do you think?") ==
"What do you think?");
assert(clean_transcript("language Chinese<astext>\xe6\x9d\xa5\xe8\xae\xa9\xe4\xbd\xa0\xe5\x8f\x91\xe7\x8e\xb0\xe6\xa0\x87\xe7\xad\xbe\xe3\x80\x82") ==
"\xe6\x9d\xa5\xe8\xae\xa9\xe4\xbd\xa0\xe5\x8f\x91\xe7\x8e\xb0\xe6\xa0\x87\xe7\xad\xbe\xe3\x80\x82");
assert(clean_transcript("language German<asr_text>Ich habe keine Idee.") ==
"Ich habe keine Idee.");
assert(clean_transcript("language Japanese<asr_text>\xe3\x81\x82\xe3\x82\x8a\xe3\x81\x8c\xe3\x81\xa8\xe3\x81\x86\xe3\x81\x94\xe3\x81\x96\xe3\x81\x84\xe3\x81\xbe\xe3\x81\x99\xe3\x80\x82") ==
"\xe3\x81\x82\xe3\x82\x8a\xe3\x81\x8c\xe3\x81\xa8\xe3\x81\x86\xe3\x81\x94\xe3\x81\x96\xe3\x81\x84\xe3\x81\xbe\xe3\x81\x99\xe3\x80\x82");
assert(clean_transcript(" \n\tlanguage English<asr_text>Hello") == "Hello");
assert(clean_transcript("Language matters.") == "Language matters.");
assert(clean_transcript("language English") == "language English");
}
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