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:
parent
04abac1bb5
commit
2ad2bef304
5 changed files with 209 additions and 24 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue