11 Commits
Author SHA1 Message Date
intelligence 570624fb8b try again 2026-08-29 19:17:47 +02:00
intelligence 5517792387 fix and remove debug
Android / build-android (push) Successful in 2m30s
2026-08-29 19:06:16 +02:00
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
52 changed files with 2952 additions and 15 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
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
- 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: 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
+4
View File
@@ -1,2 +1,6 @@
/build/
/artifacts/
/android/.gradle/
/android/build/
/android/local.properties
/android/app/build/
+12 -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 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,16 @@ 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)
endif()
set(CPACK_GENERATOR "DEB")
set(CPACK_PACKAGE_NAME "transui")
set(CPACK_PACKAGE_CONTACT "Transui contributors")
+53
View File
@@ -0,0 +1,53 @@
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'
}
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
}
}
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>
@@ -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,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[] wav) {
if (config.endpoint.isEmpty()) {
return Result.error("No transcription endpoint is configured", SpeechRecognizer.ERROR_CLIENT);
}
return request(config.endpoint, config.model, config.apiKey, config.prompt, wav);
}
void cancel() {
HttpURLConnection connection = activeConnection;
if (connection != null) connection.disconnect();
}
private Result request(
String endpoint,
String model,
String key,
String prompt,
byte[] wav) {
HttpURLConnection connection = null;
try {
String boundary = "transui-" + UUID.randomUUID();
byte[] requestBody = multipartBody(boundary, model, prompt, wav);
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[] wav)
throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(wav.length + 1024);
try (DataOutputStream output = new DataOutputStream(bytes)) {
writeField(output, boundary, "response_format", "json");
if (!model.isEmpty()) writeField(output, boundary, "model", model);
if (!prompt.isEmpty()) writeField(output, boundary, "prompt", prompt);
output.writeBytes("--" + boundary + "\r\n");
output.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\n");
output.writeBytes("Content-Type: audio/wav\r\n\r\n");
output.write(wav);
output.writeBytes("\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,285 @@
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;
TranscriptionClient.Result result = client.transcribe(
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
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,164 @@
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();
TranscriptionClient.Result result = client.transcribe(
config, WavEncoder.encodePcm16Mono(pcm, SAMPLE_RATE));
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,44 @@
package io.github.transui.android;
import java.io.ByteArrayOutputStream;
final class WavEncoder {
private static final int HEADER_SIZE = 44;
private WavEncoder() {}
static byte[] encodePcm16Mono(byte[] pcm, int sampleRate) {
ByteArrayOutputStream output = new ByteArrayOutputStream(HEADER_SIZE + pcm.length);
writeAscii(output, "RIFF");
writeInt(output, 36 + pcm.length);
writeAscii(output, "WAVE");
writeAscii(output, "fmt ");
writeInt(output, 16);
writeShort(output, 1);
writeShort(output, 1);
writeInt(output, sampleRate);
writeInt(output, sampleRate * 2);
writeShort(output, 2);
writeShort(output, 16);
writeAscii(output, "data");
writeInt(output, pcm.length);
output.write(pcm, 0, pcm.length);
return output.toByteArray();
}
private static void writeAscii(ByteArrayOutputStream output, String value) {
for (int index = 0; index < value.length(); index++) output.write(value.charAt(index));
}
private static void writeShort(ByteArrayOutputStream output, int value) {
output.write(value & 0xff);
output.write((value >>> 8) & 0xff);
}
private static void writeInt(ByteArrayOutputStream output, int value) {
output.write(value & 0xff);
output.write((value >>> 8) & 0xff);
output.write((value >>> 16) & 0xff);
output.write((value >>> 24) & 0xff);
}
}
@@ -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"));
}
}
@@ -0,0 +1,35 @@
package io.github.transui.android;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import java.nio.charset.StandardCharsets;
import org.junit.Test;
public final class WavEncoderTest {
@Test
public void writesPcm16MonoHeaderAndPayload() {
byte[] pcm = new byte[]{1, 2, 3, 4};
byte[] wav = WavEncoder.encodePcm16Mono(pcm, 16_000);
assertEquals("RIFF", ascii(wav, 0, 4));
assertEquals(40, littleEndianInt(wav, 4));
assertEquals("WAVE", ascii(wav, 8, 4));
assertEquals(16_000, littleEndianInt(wav, 24));
assertEquals(32_000, littleEndianInt(wav, 28));
assertEquals(4, littleEndianInt(wav, 40));
assertArrayEquals(pcm, new byte[]{wav[44], wav[45], wav[46], wav[47]});
}
private static String ascii(byte[] bytes, int offset, int length) {
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
}
private static int littleEndianInt(byte[] bytes, int offset) {
return (bytes[offset] & 0xff)
| ((bytes[offset + 1] & 0xff) << 8)
| ((bytes[offset + 2] & 0xff) << 16)
| ((bytes[offset + 3] & 0xff) << 24);
}
}
+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'
+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);
+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");
}