From e3dd14cef65f31dd42f616bb14ef0502ba023dad Mon Sep 17 00:00:00 2001 From: intelligence Date: Sat, 29 Aug 2026 14:51:02 +0200 Subject: [PATCH] feat:and --- .gitea/workflows/android.yml | 75 +++++ .gitignore | 5 + android/app/build.gradle | 53 ++++ android/app/src/main/AndroidManifest.xml | 39 +++ .../io/github/transui/android/AppConfig.java | 36 +++ .../transui/android/ConfigRepository.java | 53 ++++ .../github/transui/android/MainActivity.java | 136 +++++++++ .../github/transui/android/SecretStore.java | 62 ++++ .../transui/android/TranscriptCleaner.java | 16 + .../transui/android/TranscriptionClient.java | 162 ++++++++++ .../android/TransuiRecognitionService.java | 285 ++++++++++++++++++ .../io/github/transui/android/WavEncoder.java | 44 +++ .../app/src/main/res/drawable/ic_launcher.xml | 13 + android/app/src/main/res/drawable/panel.xml | 6 + .../app/src/main/res/layout/activity_main.xml | 142 +++++++++ .../app/src/main/res/values-night/colors.xml | 9 + .../app/src/main/res/values-night/styles.xml | 11 + android/app/src/main/res/values/colors.xml | 9 + android/app/src/main/res/values/strings.xml | 26 ++ android/app/src/main/res/values/styles.xml | 31 ++ android/app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 17 ++ .../src/main/res/xml/recognition_service.xml | 3 + .../android/TranscriptCleanerTest.java | 24 ++ .../transui/android/WavEncoderTest.java | 35 +++ android/build.gradle | 3 + android/gradle.properties | 3 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 ++++++++++++++++ android/gradlew.bat | 94 ++++++ android/settings.gradle | 18 ++ scripts/publish-release-assets.sh | 2 +- 33 files changed, 1678 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/android.yml create mode 100644 android/app/build.gradle create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/io/github/transui/android/AppConfig.java create mode 100644 android/app/src/main/java/io/github/transui/android/ConfigRepository.java create mode 100644 android/app/src/main/java/io/github/transui/android/MainActivity.java create mode 100644 android/app/src/main/java/io/github/transui/android/SecretStore.java create mode 100644 android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java create mode 100644 android/app/src/main/java/io/github/transui/android/TranscriptionClient.java create mode 100644 android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java create mode 100644 android/app/src/main/java/io/github/transui/android/WavEncoder.java create mode 100644 android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 android/app/src/main/res/drawable/panel.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/main/res/xml/recognition_service.xml create mode 100644 android/app/src/test/java/io/github/transui/android/TranscriptCleanerTest.java create mode 100644 android/app/src/test/java/io/github/transui/android/WavEncoderTest.java create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 0000000..f041e77 --- /dev/null +++ b/.gitea/workflows/android.yml @@ -0,0 +1,75 @@ +name: Android + +on: + push: + pull_request: + 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 + - uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ gitea.event_name == 'pull_request' }} + - 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 debug APK + working-directory: android + run: ./gradlew --no-daemon assembleDebug + - 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 + cp app/build/outputs/apk/debug/app-debug.apk artifacts/transui-android-debug.apk + if [ -f app/build/outputs/apk/release/app-release.apk ]; then + cp app/build/outputs/apk/release/app-release.apk artifacts/transui-android-release.apk + fi + cd artifacts + sha256sum ./*.apk > SHA256SUMS-android + - name: Attach APKs to the Gitea release + if: gitea.event_name == 'release' + env: + ARTIFACT_DIR: android/artifacts + 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: android/artifacts/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 1f7d35e..51c64b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ /build/ /artifacts/ +/android/.gradle/ +/android/build/ +/android/local.properties +/android/app/build/ +/android/artifacts/ diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..18e8197 --- /dev/null +++ b/android/app/build.gradle @@ -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 1 + versionName '0.1.0' + + 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' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4238672 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/github/transui/android/AppConfig.java b/android/app/src/main/java/io/github/transui/android/AppConfig.java new file mode 100644 index 0000000..0eeabd0 --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/AppConfig.java @@ -0,0 +1,36 @@ +package io.github.transui.android; + +final class AppConfig { + final String remoteEndpoint; + final String remoteModel; + final String remoteKey; + final String localEndpoint; + final String localModel; + final String localKey; + final String prompt; + final boolean remoteFirst; + + AppConfig( + String remoteEndpoint, + String remoteModel, + String remoteKey, + String localEndpoint, + String localModel, + String localKey, + String prompt, + boolean remoteFirst) { + this.remoteEndpoint = remoteEndpoint; + this.remoteModel = remoteModel; + this.remoteKey = remoteKey; + this.localEndpoint = localEndpoint; + this.localModel = localModel; + this.localKey = localKey; + this.prompt = prompt; + this.remoteFirst = remoteFirst; + } + + boolean hasUsableEndpoint() { + if (remoteFirst && !remoteEndpoint.isEmpty()) return true; + return !localEndpoint.isEmpty(); + } +} diff --git a/android/app/src/main/java/io/github/transui/android/ConfigRepository.java b/android/app/src/main/java/io/github/transui/android/ConfigRepository.java new file mode 100644 index 0000000..427e91f --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/ConfigRepository.java @@ -0,0 +1,53 @@ +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); + } + + AppConfig load() { + String remoteKey = decryptOrEmpty(preferences.getString("remote_key", "")); + String localKey = decryptOrEmpty(preferences.getString("local_key", "")); + return new AppConfig( + preferences.getString("remote_endpoint", ""), + preferences.getString("remote_model", ""), + remoteKey, + preferences.getString("local_endpoint", "http://localhost:8080"), + preferences.getString("local_model", ""), + localKey, + preferences.getString("prompt", ""), + preferences.getBoolean("remote_first", true)); + } + + void save(AppConfig config) throws GeneralSecurityException, IOException { + String remoteKey = SecretStore.encrypt(config.remoteKey); + String localKey = SecretStore.encrypt(config.localKey); + preferences.edit() + .putString("remote_endpoint", config.remoteEndpoint) + .putString("remote_model", config.remoteModel) + .putString("remote_key", remoteKey) + .putString("local_endpoint", config.localEndpoint) + .putString("local_model", config.localModel) + .putString("local_key", localKey) + .putString("prompt", config.prompt) + .putBoolean("remote_first", config.remoteFirst) + .apply(); + } + + private static String decryptOrEmpty(String value) { + try { + return SecretStore.decrypt(value); + } catch (GeneralSecurityException | IOException | IllegalArgumentException ignored) { + return ""; + } + } +} diff --git a/android/app/src/main/java/io/github/transui/android/MainActivity.java b/android/app/src/main/java/io/github/transui/android/MainActivity.java new file mode 100644 index 0000000..78d1629 --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/MainActivity.java @@ -0,0 +1,136 @@ +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 EditText remoteEndpoint; + private EditText remoteModel; + private EditText remoteKey; + private EditText localEndpoint; + private EditText localModel; + private EditText localKey; + private EditText prompt; + private Switch remoteFirst; + 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.choose_provider).setOnClickListener(view -> { + if (saveConfig()) openVoiceInputSettings(); + }); + } + + @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() { + remoteEndpoint = findViewById(R.id.remote_endpoint); + remoteModel = findViewById(R.id.remote_model); + remoteKey = findViewById(R.id.remote_key); + localEndpoint = findViewById(R.id.local_endpoint); + localModel = findViewById(R.id.local_model); + localKey = findViewById(R.id.local_key); + prompt = findViewById(R.id.prompt); + remoteFirst = findViewById(R.id.remote_first); + providerStatus = findViewById(R.id.provider_status); + microphone = findViewById(R.id.microphone); + } + + private void showConfig(AppConfig config) { + remoteEndpoint.setText(config.remoteEndpoint); + remoteModel.setText(config.remoteModel); + remoteKey.setText(config.remoteKey); + localEndpoint.setText(config.localEndpoint); + localModel.setText(config.localModel); + localKey.setText(config.localKey); + prompt.setText(config.prompt); + remoteFirst.setChecked(config.remoteFirst); + } + + private boolean saveConfig() { + AppConfig config = new AppConfig( + value(remoteEndpoint).trim(), + value(remoteModel).trim(), + value(remoteKey).trim(), + value(localEndpoint).trim(), + value(localModel).trim(), + value(localKey).trim(), + value(prompt), + remoteFirst.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 openVoiceInputSettings() { + try { + startActivity(new Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)); + } catch (ActivityNotFoundException error) { + Toast.makeText(this, R.string.voice_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(); + } +} diff --git a/android/app/src/main/java/io/github/transui/android/SecretStore.java b/android/app/src/main/java/io/github/transui/android/SecretStore.java new file mode 100644 index 0000000..03f300b --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/SecretStore.java @@ -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(); + } +} diff --git a/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java b/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java new file mode 100644 index 0000000..b266eb4 --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java @@ -0,0 +1,16 @@ +package io.github.transui.android; + +final class TranscriptCleaner { + private static final String QWEN_LANGUAGE_PREFIX = "language "; + private static final String QWEN_TEXT_MARKER = ""; + + private TranscriptCleaner() {} + + static String clean(String transcript) { + // Qwen3-ASR prefixes transcripts with the detected language and this marker. + if (!transcript.startsWith(QWEN_LANGUAGE_PREFIX)) return transcript; + int marker = transcript.indexOf(QWEN_TEXT_MARKER, QWEN_LANGUAGE_PREFIX.length()); + if (marker < 0) return transcript; + return transcript.substring(marker + QWEN_TEXT_MARKER.length()); + } +} diff --git a/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java new file mode 100644 index 0000000..9a02ac3 --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/TranscriptionClient.java @@ -0,0 +1,162 @@ +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) { + Result remoteResult = null; + if (config.remoteFirst && !config.remoteEndpoint.isEmpty()) { + remoteResult = request( + config.remoteEndpoint, config.remoteModel, config.remoteKey, config.prompt, wav); + if (remoteResult.text != null) return remoteResult; + } + + if (!config.localEndpoint.isEmpty()) { + Result localResult = request( + config.localEndpoint, config.localModel, config.localKey, config.prompt, wav); + if (localResult.text != null || remoteResult == null) return localResult; + return Result.error( + remoteResult.error + "; local: " + localResult.error, + localResult.errorCode); + } + + return remoteResult != null + ? remoteResult + : Result.error("No transcription endpoint is configured", SpeechRecognizer.ERROR_CLIENT); + } + + 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); + } + } +} diff --git a/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java new file mode 100644 index 0000000..80cafff --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/TransuiRecognitionService.java @@ -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 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)); + } +} diff --git a/android/app/src/main/java/io/github/transui/android/WavEncoder.java b/android/app/src/main/java/io/github/transui/android/WavEncoder.java new file mode 100644 index 0000000..d19140b --- /dev/null +++ b/android/app/src/main/java/io/github/transui/android/WavEncoder.java @@ -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); + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..2672d66 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/drawable/panel.xml b/android/app/src/main/res/drawable/panel.xml new file mode 100644 index 0000000..aa41d25 --- /dev/null +++ b/android/app/src/main/res/drawable/panel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..a8397c4 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +