From 066c7d758e8fc895cad645644dacef42ae7414bb Mon Sep 17 00:00:00 2001 From: intelligence Date: Sat, 29 Aug 2026 15:43:35 +0200 Subject: [PATCH] fix:parser and ui --- CMakeLists.txt | 14 +- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 24 ++ .../io/github/transui/android/AppConfig.java | 5 +- .../transui/android/ConfigRepository.java | 4 +- .../github/transui/android/MainActivity.java | 7 +- .../transui/android/PushToTalkButton.java | 51 +++++ .../android/SpeechRecognitionActivity.java | 205 ++++++++++++++++++ .../transui/android/TranscriptCleaner.java | 26 ++- .../android/TransuiInputMethodService.java | 146 +++++++++++++ .../android/VoiceRecordingController.java | 158 ++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 8 + .../src/main/res/layout/recognition_panel.xml | 57 +++++ .../app/src/main/res/layout/voice_input.xml | 59 +++++ .../app/src/main/res/values-night/styles.xml | 9 + android/app/src/main/res/values/strings.xml | 12 + android/app/src/main/res/values/styles.xml | 9 + android/app/src/main/res/xml/input_method.xml | 11 + .../android/TranscriptCleanerTest.java | 18 +- src/main.cpp | 14 +- src/transcript_cleaner.cpp | 33 +++ src/transcript_cleaner.h | 5 + tests/transcript_cleaner_test.cpp | 17 ++ 23 files changed, 867 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/io/github/transui/android/PushToTalkButton.java create mode 100644 android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java create mode 100644 android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java create mode 100644 android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java create mode 100644 android/app/src/main/res/layout/recognition_panel.xml create mode 100644 android/app/src/main/res/layout/voice_input.xml create mode 100644 android/app/src/main/res/xml/input_method.xml create mode 100644 src/transcript_cleaner.cpp create mode 100644 src/transcript_cleaner.h create mode 100644 tests/transcript_cleaner_test.cpp 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" /> + +