1
0
Fork 0

More robust voice input on Android 12+. It will now choose dynamically between offline and online mode and download the necessary files automatically, instead of just failing

This commit is contained in:
sspanak 2025-03-10 16:37:58 +02:00 committed by Dimo Karaivanov
parent 04abac1bb5
commit 2ad2bef304
5 changed files with 209 additions and 24 deletions

View file

@ -75,7 +75,15 @@ abstract class VoiceHandler extends TypingHandler {
private void onVoiceInputError(VoiceInputError error) { private void onVoiceInputError(VoiceInputError error) {
if (error.isIrrelevantToUser()) { if (error.isLanguageMissing()) {
if (voiceInputOps.downloadLanguage(mLanguage)) {
Logger.i(LOG_TAG, "Downloading voice input language '" + mLanguage.getName() + "'");
resetStatus();
} else {
Logger.e(LOG_TAG, "Could not start download for voice input language '" + mLanguage.getName() + "'");
statusBar.setError(error.toString());
}
} else if (error.isIrrelevantToUser()) {
Logger.i(LOG_TAG, "Ignoring voice input. " + error.debugMessage); Logger.i(LOG_TAG, "Ignoring voice input. " + error.debugMessage);
resetStatus(); // re-enable the function keys resetStatus(); // re-enable the function keys
} else { } else {

View file

@ -0,0 +1,24 @@
package io.github.sspanak.tt9.ime.voice;
import android.content.Context;
import android.speech.SpeechRecognizer;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.github.sspanak.tt9.hacks.DeviceInfo;
import io.github.sspanak.tt9.languages.Language;
public class SpeechRecognizerSupportLegacy {
final boolean isOnDeviceRecognitionAvailable;
final boolean isRecognitionAvailable;
SpeechRecognizerSupportLegacy(@NonNull Context ims) {
isOnDeviceRecognitionAvailable = DeviceInfo.AT_LEAST_ANDROID_12 && SpeechRecognizer.isOnDeviceRecognitionAvailable(ims);
isRecognitionAvailable = SpeechRecognizer.isRecognitionAvailable(ims);
}
SpeechRecognizerSupportLegacy setLanguage(Language l) { return this; }
void checkOfflineSupport(@NonNull Runnable onSupportChecked) { onSupportChecked.run(); }
boolean isLanguageSupportedOffline(@Nullable Language l) { return false; }
}

View file

@ -0,0 +1,91 @@
package io.github.sspanak.tt9.ime.voice;
import android.content.Context;
import android.os.Build;
import android.speech.RecognitionSupport;
import android.speech.RecognitionSupportCallback;
import android.speech.SpeechRecognizer;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.Executors;
import io.github.sspanak.tt9.hacks.DeviceInfo;
import io.github.sspanak.tt9.languages.Language;
@RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
class SpeechRecognizerSupportModern extends SpeechRecognizerSupportLegacy implements RecognitionSupportCallback {
private final HashSet<String> missingOfflineLanguages = new HashSet<>();
private final HashSet<String> availableOfflineLanguages = new HashSet<>();
private final Context ims;
private String locale;
private Runnable onSupportChecked;
private SpeechRecognizer recognizer;
SpeechRecognizerSupportModern(@NonNull Context ims) {
super(ims);
this.ims = ims;
}
@Override
SpeechRecognizerSupportModern setLanguage(@Nullable Language language) {
locale = language == null ? null : VoiceInputOps.getLocale(language);
return this;
}
@Override
void checkOfflineSupport(@NonNull Runnable onSupportChecked) {
if (
locale == null
|| !DeviceInfo.AT_LEAST_ANDROID_13
|| !isOnDeviceRecognitionAvailable
|| missingOfflineLanguages.contains(locale)
|| availableOfflineLanguages.contains(locale)
) {
onSupportChecked.run();
return;
}
this.onSupportChecked = onSupportChecked;
recognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(ims);
recognizer.checkRecognitionSupport(VoiceInputOps.createIntent(locale), Executors.newSingleThreadExecutor(), this);
}
@Override
boolean isLanguageSupportedOffline(@Nullable Language language) {
return language != null && isOnDeviceRecognitionAvailable && availableOfflineLanguages.contains(VoiceInputOps.getLocale(language));
}
public void onSupportResult(@NonNull RecognitionSupport recognitionSupport) {
recognizer.destroy();
List<String> locales = recognitionSupport.getSupportedOnDeviceLanguages();
if (locales.contains(locale)) {
availableOfflineLanguages.add(locale);
missingOfflineLanguages.remove(locale);
} else {
availableOfflineLanguages.remove(locale);
missingOfflineLanguages.add(locale);
}
onSupportChecked.run();
}
public void onError(int error) {
recognizer.destroy();
missingOfflineLanguages.add(locale);
onSupportChecked.run();
}
}

View file

@ -41,6 +41,11 @@ public class VoiceInputError {
} }
public boolean isLanguageMissing() {
return DeviceInfo.AT_LEAST_ANDROID_12 && code == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE;
}
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {

View file

@ -2,10 +2,13 @@ package io.github.sspanak.tt9.ime.voice;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.speech.RecognizerIntent; import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer; import android.speech.SpeechRecognizer;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList; import java.util.ArrayList;
@ -16,14 +19,13 @@ import io.github.sspanak.tt9.util.ConsumerCompat;
import io.github.sspanak.tt9.util.Logger; import io.github.sspanak.tt9.util.Logger;
public class VoiceInputOps { public class VoiceInputOps {
private final boolean isOnDeviceRecognitionAvailable; private final static String LOG_TAG = VoiceInputOps.class.getSimpleName();
private final boolean isRecognitionAvailable;
private final Context ims; private final Context ims;
private Language language; private Language language;
private SpeechRecognizer speechRecognizer;
private final VoiceListener listener; private final VoiceListener listener;
private final SpeechRecognizerSupportLegacy recognizerSupport;
private SpeechRecognizer speechRecognizer;
private final ConsumerCompat<String> onStopListening; private final ConsumerCompat<String> onStopListening;
private final ConsumerCompat<VoiceInputError> onListeningError; private final ConsumerCompat<VoiceInputError> onListeningError;
@ -35,9 +37,8 @@ public class VoiceInputOps {
ConsumerCompat<String> onStop, ConsumerCompat<String> onStop,
ConsumerCompat<VoiceInputError> onError ConsumerCompat<VoiceInputError> onError
) { ) {
isOnDeviceRecognitionAvailable = DeviceInfo.AT_LEAST_ANDROID_12 && SpeechRecognizer.isOnDeviceRecognitionAvailable(ims);
isRecognitionAvailable = SpeechRecognizer.isRecognitionAvailable(ims);
listener = new VoiceListener(ims, onStart, this::onStop, this::onError); listener = new VoiceListener(ims, onStart, this::onStop, this::onError);
recognizerSupport = DeviceInfo.AT_LEAST_ANDROID_13 ? new SpeechRecognizerSupportModern(ims) : new SpeechRecognizerSupportLegacy(ims);
onStopListening = onStop != null ? onStop : result -> {}; onStopListening = onStop != null ? onStop : result -> {};
onListeningError = onError != null ? onError : error -> {}; onListeningError = onError != null ? onError : error -> {};
@ -46,12 +47,33 @@ public class VoiceInputOps {
} }
private void createRecognizer() { static String getLocale(@NonNull Language lang) {
if (DeviceInfo.AT_LEAST_ANDROID_12 && isOnDeviceRecognitionAvailable) { return lang.getLocale().toString().replace("_", "-");
}
static Intent createIntent(@NonNull String locale) {
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
return intent;
}
static Intent createIntent(@NonNull Language language) {
return createIntent(getLocale(language));
}
private void createRecognizer(@Nullable Language language) {
if (DeviceInfo.AT_LEAST_ANDROID_13 && recognizerSupport.isLanguageSupportedOffline(language)) {
Logger.d(LOG_TAG, "Creating on-device SpeechRecognizer...");
speechRecognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(ims); speechRecognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(ims);
} else if (isRecognitionAvailable) { } else if (recognizerSupport.isRecognitionAvailable) {
Logger.d(LOG_TAG, "Creating online SpeechRecognizer...");
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(ims); speechRecognizer = SpeechRecognizer.createSpeechRecognizer(ims);
} else { } else {
Logger.d(LOG_TAG, "Cannot create SpeechRecognizer, recognition not available.");
return; return;
} }
@ -60,7 +82,7 @@ public class VoiceInputOps {
public boolean isAvailable() { public boolean isAvailable() {
return isRecognitionAvailable || isOnDeviceRecognitionAvailable; return recognizerSupport.isRecognitionAvailable || recognizerSupport.isOnDeviceRecognitionAvailable;
} }
@ -69,7 +91,7 @@ public class VoiceInputOps {
} }
public void listen(Language language) { public void listen(@Nullable Language language) {
if (language == null) { if (language == null) {
onListeningError.accept(new VoiceInputError(ims, VoiceInputError.ERROR_INVALID_LANGUAGE)); onListeningError.accept(new VoiceInputError(ims, VoiceInputError.ERROR_INVALID_LANGUAGE));
return; return;
@ -85,20 +107,37 @@ public class VoiceInputOps {
return; return;
} }
createRecognizer();
this.language = language; this.language = language;
String locale = language.getLocale().toString().replace("_", "-"); recognizerSupport.setLanguage(language).checkOfflineSupport(this::listenAsync);
}
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale); private void listenAsync() {
intent.putExtra(RecognizerIntent.EXTRA_PROMPT, toString()); Handler mainHandler = new Handler(Looper.getMainLooper());
mainHandler.post(this::listen);
}
private void listen() {
if (!isAvailable()) {
onListeningError.accept(new VoiceInputError(ims, VoiceInputError.ERROR_NOT_AVAILABLE));
return;
}
if (isListening()) {
onListeningError.accept(new VoiceInputError(ims, SpeechRecognizer.ERROR_RECOGNIZER_BUSY));
return;
}
createRecognizer(language);
String locale = getLocale(language);
try { try {
speechRecognizer.startListening(intent); speechRecognizer.startListening(createIntent(locale));
Logger.d(getClass().getSimpleName(), "SpeechRecognizer started for locale: " + locale); Logger.d(LOG_TAG, "SpeechRecognizer started for locale: " + locale);
} catch (SecurityException e) { } catch (SecurityException e) {
Logger.e(getClass().getSimpleName(), "SpeechRecognizer start failed due to a SecurityException. " + e.getMessage()); Logger.e(LOG_TAG, "SpeechRecognizer start failed due to a SecurityException. " + e.getMessage());
onError(new VoiceInputError(ims, VoiceInputError.ERROR_CANNOT_BIND_TO_VOICE_SERVICE)); onError(new VoiceInputError(ims, VoiceInputError.ERROR_CANNOT_BIND_TO_VOICE_SERVICE));
} }
} }
@ -122,19 +161,36 @@ public class VoiceInputOps {
speechRecognizer.destroy(); speechRecognizer.destroy();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (i < 2) { if (i < 2) {
Logger.e(getClass().getSimpleName(), "SpeechRecognizer destroy failed. " + e.getMessage() + ". Retrying..."); Logger.e(LOG_TAG, "SpeechRecognizer destroy failed. " + e.getMessage() + ". Retrying...");
continue; continue;
} else { } else {
Logger.e(getClass().getSimpleName(), "SpeechRecognizer destroy failed. " + e.getMessage() + ". Giving up and just nulling the reference."); Logger.e(LOG_TAG, "SpeechRecognizer destroy failed. " + e.getMessage() + ". Giving up and just nulling the reference.");
} }
} }
speechRecognizer = null; speechRecognizer = null;
Logger.d(getClass().getSimpleName(), "SpeechRecognizer destroyed"); Logger.d(LOG_TAG, "SpeechRecognizer destroyed");
} }
} }
public boolean downloadLanguage(Language language) {
if (!DeviceInfo.AT_LEAST_ANDROID_13 || !recognizerSupport.isLanguageSupportedOffline(language) || isListening()) {
return false;
}
createRecognizer(language);
if (speechRecognizer == null) {
return false;
}
speechRecognizer.triggerModelDownload(createIntent(language));
destroy();
return true;
}
private void onStop(ArrayList<String> results) { private void onStop(ArrayList<String> results) {
destroy(); destroy();
onStopListening.accept(results.isEmpty() ? null : results.get(0)); onStopListening.accept(results.isEmpty() ? null : results.get(0));
@ -146,6 +202,7 @@ public class VoiceInputOps {
onListeningError.accept(error); onListeningError.accept(error);
} }
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {