diff --git a/CMakeLists.txt b/CMakeLists.txt
index c3e26f8..261d273 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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")
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 18e8197..9ff531c 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -10,8 +10,8 @@ android {
applicationId 'io.github.transui.android'
minSdk 26
targetSdk 35
- versionCode 1
- versionName '0.1.0'
+ versionCode 2
+ versionName '0.1.1'
testInstrumentationRunner 'android.test.InstrumentationTestRunner'
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4238672..1af7091 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
index 0adbfa0..9bdbf9e 100644
--- a/android/app/src/main/java/io/github/transui/android/AppConfig.java
+++ b/android/app/src/main/java/io/github/transui/android/AppConfig.java
@@ -5,16 +5,19 @@ final class AppConfig {
final String model;
final String apiKey;
final String prompt;
+ final boolean pushToTalk;
AppConfig(
String endpoint,
String model,
String apiKey,
- String prompt) {
+ String prompt,
+ boolean pushToTalk) {
this.endpoint = endpoint;
this.model = model;
this.apiKey = apiKey;
this.prompt = prompt;
+ this.pushToTalk = pushToTalk;
}
boolean hasUsableEndpoint() {
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
index b25a465..b3f4fca 100644
--- a/android/app/src/main/java/io/github/transui/android/ConfigRepository.java
+++ b/android/app/src/main/java/io/github/transui/android/ConfigRepository.java
@@ -20,7 +20,8 @@ final class ConfigRepository {
preferences.getString("remote_endpoint", ""),
preferences.getString("remote_model", ""),
decryptOrEmpty(preferences.getString("remote_key", "")),
- preferences.getString("prompt", ""));
+ preferences.getString("prompt", ""),
+ preferences.getBoolean("push_to_talk", false));
}
void save(AppConfig config) throws GeneralSecurityException, IOException {
@@ -29,6 +30,7 @@ final class ConfigRepository {
.putString("remote_model", config.model)
.putString("remote_key", SecretStore.encrypt(config.apiKey))
.putString("prompt", config.prompt)
+ .putBoolean("push_to_talk", config.pushToTalk)
.apply();
}
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
index 7f7b86e..b5187fb 100644
--- a/android/app/src/main/java/io/github/transui/android/MainActivity.java
+++ b/android/app/src/main/java/io/github/transui/android/MainActivity.java
@@ -10,6 +10,7 @@ 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;
@@ -25,6 +26,7 @@ public final class MainActivity extends Activity {
private EditText model;
private EditText apiKey;
private EditText prompt;
+ private Switch pushToTalk;
private TextView providerStatus;
private Button microphone;
private ConfigRepository repository;
@@ -59,6 +61,7 @@ public final class MainActivity extends Activity {
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);
}
@@ -68,6 +71,7 @@ public final class MainActivity extends Activity {
model.setText(config.model);
apiKey.setText(config.apiKey);
prompt.setText(config.prompt);
+ pushToTalk.setChecked(config.pushToTalk);
}
private boolean saveConfig() {
@@ -75,7 +79,8 @@ public final class MainActivity extends Activity {
value(endpoint).trim(),
value(model).trim(),
value(apiKey).trim(),
- value(prompt));
+ value(prompt),
+ pushToTalk.isChecked());
try {
repository.save(config);
Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
diff --git a/android/app/src/main/java/io/github/transui/android/PushToTalkButton.java b/android/app/src/main/java/io/github/transui/android/PushToTalkButton.java
new file mode 100644
index 0000000..b164b6b
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/PushToTalkButton.java
@@ -0,0 +1,51 @@
+package io.github.transui.android;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.Button;
+
+public final class PushToTalkButton extends Button {
+ private Runnable pressedAction;
+ private Runnable releasedAction;
+
+ public PushToTalkButton(Context context, AttributeSet attributes) {
+ super(context, attributes);
+ }
+
+ void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
+ this.pressedAction = pressedAction;
+ this.releasedAction = releasedAction;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (pressedAction == null || releasedAction == null) return super.onTouchEvent(event);
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ pressedAction.run();
+ setPressed(true);
+ return true;
+ }
+ if (event.getAction() == MotionEvent.ACTION_UP) {
+ releasedAction.run();
+ setPressed(false);
+ super.performClick();
+ return true;
+ }
+ if (event.getAction() == MotionEvent.ACTION_CANCEL) {
+ releasedAction.run();
+ setPressed(false);
+ return true;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean performClick() {
+ if (pressedAction != null && releasedAction != null) {
+ pressedAction.run();
+ releasedAction.run();
+ }
+ return super.performClick();
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java b/android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java
new file mode 100644
index 0000000..f89d95f
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java
@@ -0,0 +1,205 @@
+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.widget.Button;
+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 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);
+ 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() {
+ if (config.pushToTalk) {
+ record.setText(R.string.hold_to_talk);
+ record.setPushToTalkActions(this::startRecording, this::stopRecording);
+ } else {
+ record.setText(R.string.stop);
+ record.setOnClickListener(view -> stopRecording());
+ }
+ }
+
+ 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);
+ controller.stop();
+ }
+
+ private void showUnavailable(int message) {
+ status.setText(message);
+ elapsed.setText("");
+ record.setEnabled(false);
+ }
+
+ 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 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 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);
+ recordingStartedAt = SystemClock.elapsedRealtime();
+ elapsed.setText(R.string.elapsed_zero);
+ handler.post(updateElapsed);
+ });
+ }
+
+ @Override
+ public void onTranscribing() {
+ runOnUiThread(() -> {
+ handler.removeCallbacks(updateElapsed);
+ status.setText(R.string.transcribing);
+ record.setEnabled(false);
+ });
+ }
+
+ @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));
+ }
+ }
+}
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
index b266eb4..aa1dc69 100644
--- a/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java
+++ b/android/app/src/main/java/io/github/transui/android/TranscriptCleaner.java
@@ -2,15 +2,29 @@ package io.github.transui.android;
final class TranscriptCleaner {
private static final String QWEN_LANGUAGE_PREFIX = "language ";
- private static final String QWEN_TEXT_MARKER = "";
+ private static final String[] QWEN_TEXT_MARKERS = {"", ""};
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());
+ // 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());
}
}
diff --git a/android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java b/android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java
new file mode 100644
index 0000000..bc181cb
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java
@@ -0,0 +1,146 @@
+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.inputmethod.InputConnection;
+import android.widget.Button;
+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 PushToTalkButton record;
+ private AppConfig config;
+ private VoiceRecordingController controller;
+ private boolean sessionActive;
+
+ @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);
+ record = view.findViewById(R.id.voice_record);
+ view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
+ return view;
+ }
+
+ @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.setOnClickListener(null);
+ record.setPushToTalkActions(null, null);
+ if (config.pushToTalk) {
+ record.setText(R.string.hold_to_talk);
+ record.setPushToTalkActions(this::startRecording, this::stopRecording);
+ } else {
+ record.setText(R.string.stop);
+ record.setOnClickListener(view -> stopRecording());
+ }
+ }
+
+ 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);
+ controller.stop();
+ }
+
+ private void cancelSession() {
+ if (controller != null) controller.cancel();
+ controller = null;
+ sessionActive = false;
+ }
+
+ private void showUnavailable(int message) {
+ status.setText(message);
+ record.setEnabled(false);
+ }
+
+ 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));
+ }
+
+ @Override
+ public void onTranscribing() {
+ handler.post(() -> {
+ status.setText(R.string.transcribing);
+ record.setEnabled(false);
+ });
+ }
+
+ @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));
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java b/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java
new file mode 100644
index 0000000..f960825
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java
@@ -0,0 +1,158 @@
+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 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();
+
+ 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);
+ short[] samples = new short[bufferBytes / 2];
+ 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);
+ 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);
+ }
+ }
+}
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index b2524eb..64fad86 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -84,6 +84,14 @@
android:maxLines="3"
android:singleLine="false" />
+
+