Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_USER_DICTIONARY" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<application android:label="@string/english_ime_name"
android:name="helium314.keyboard.latin.App"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package helium314.keyboard.keyboard.voice;

enum ListenState {
NOT_LISTENING,
WAITING,
LISTENING,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package helium314.keyboard.keyboard.voice;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.speech.RecognitionListener;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
import android.util.Log;

import java.util.ArrayList;

public final class VoiceTranscriber {
private static final String TAG = VoiceTranscriber.class.getSimpleName();
private static final Intent INTENT = new Intent(
RecognizerIntent.ACTION_RECOGNIZE_SPEECH
).putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
).putExtra(
// this is "best effort" at preventing the service from cutting the user
// off. in practice, they can ignore this or set a hard upper limit on
// the-timeout-that-shouldn't-be-there-to-begin-with. with the Google
// service, this at least affords the user a fairer amount of time than
// their IME does.
RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS,
Integer.MAX_VALUE
);

private final SpeechRecognizer mSpeech;
private ListenState mListening = ListenState.NOT_LISTENING;

public VoiceTranscriber(Context ctx) {
ctx = ctx.getApplicationContext();
// todo: user preference to use createOnDeviceSpeechRecognizer(). note
// that the Google service doesn't support this despite working without
// internet. ??
// for the older SDK versions, there's also some intent parameter that
// asks nicely for that. ultimately, i'm 80% sure whether it's actually
// on-device and private is up to whether you trust the developer. this
// should be communicated to the user.
// todo: we need to replace the recognizer as soon as the user changes
// their preferred recognizer in the system settings.
// TODO LOL: this call crashes when no service is available, so we need
// to not do any of this if there's no recognizer.
mSpeech = SpeechRecognizer.createSpeechRecognizer(ctx);
mSpeech.setRecognitionListener(new SpeechListener());
}

public void toggleListening() {
switch (mListening) {
case NOT_LISTENING -> {
// TODO LOL: permission request flow
mListening = ListenState.WAITING;
mSpeech.startListening(INTENT);
}
case WAITING -> {
// todo: have some sort of "toggle buffer" mechanism here so we
// don't just eat inputs
}
case LISTENING -> {
mListening = ListenState.WAITING;
mSpeech.stopListening();
}
}
}

private void typeOut(String text) {
Log.d(TAG, text);
}

public void stopListening() {
mSpeech.stopListening();
}

public void destroy() {
mSpeech.destroy();
}

private final class SpeechListener implements RecognitionListener {
@Override
public void onBeginningOfSpeech() {
}

@Override
public void onBufferReceived(byte[] buffer) {
}

@Override
public void onEndOfSpeech() {
// this means nothing, we don't decide when the user stops speaking.
}

@Override
public void onError(int error) {
mListening = ListenState.NOT_LISTENING;
}

@Override
public void onEvent(int eventType, Bundle params) {
}

@Override
public void onPartialResults(Bundle partialResults) {
ArrayList<String> recognitions
= partialResults.getStringArrayList(
SpeechRecognizer.RESULTS_RECOGNITION
)
;
if (recognitions == null || recognitions.isEmpty()) {
return;
}

// todo: the Google recognizer will put a leading space if it's
// not the first transcription in the recording session. instead,
// we should trim that and ourselves be the judge of whether the
// transcription should be space-padded based on caret/selection
// position and language properties.
typeOut(recognitions.get(0));
// so in theory, RESULTS_RECOGNITION is a list of different guesses
// for what the user said. in practice with the Google recognizer, I
// have only ever seen this be a singleton list. maybe to get
// additional hypotheses we need to pass some additional parameters
// in the recognizer intent? other results from a fully-fledged
// recognizer that would be helpful for correction suggestions
// include RESULTS_ALTERNATIVES and CONFIDENCE_SCORES.
}

@Override
public void onReadyForSpeech(Bundle params) {
mListening = ListenState.LISTENING;
}

@Override
public void onResults(Bundle results) {
mListening = ListenState.NOT_LISTENING;
}

@Override
public void onRmsChanged(float rmsdB) {
// todo: this could be used for a noise gauge graphic
}
}
}
29 changes: 28 additions & 1 deletion app/src/main/java/helium314/keyboard/latin/LatinIME.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import helium314.keyboard.keyboard.emoji.EmojiSearchActivity;
import helium314.keyboard.keyboard.internal.KeyboardIconsSet;
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode;
import helium314.keyboard.keyboard.voice.VoiceTranscriber;
import helium314.keyboard.latin.common.InsetsOutlineProvider;
import helium314.keyboard.dictionarypack.DictionaryPackConstants;
import helium314.keyboard.event.Event;
Expand Down Expand Up @@ -186,6 +187,7 @@ public void onReceive(Context context, Intent intent) {

private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER;

private VoiceTranscriber mVoiceTranscriber;
private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this);

public static final class UIHandler extends LeakGuardHandlerWrapper<LatinIME> {
Expand Down Expand Up @@ -549,6 +551,15 @@ public void onCreate() {
super.onCreate();

loadSettings();
// TODO LOL: this probably isn't the initialization strategy we want for
// this thing—I'm not sure this is the intended use of
// "displayContext"? and I don't know how the LatinIME lifecycle works,
// this is just here for debug purposes. we also probably want to have
// a config option for lazy vs. eager initialization of the speech
// recognizer, as that takes some time. some may prefer reducing lag at
// keyboard load, others may want to reduce lag for the first press of
// the voice key.
mVoiceTranscriber = new VoiceTranscriber(mDisplayContext);
mClipboardHistoryManager.onCreate();
mHandler.onCreate();
if (FoldableUtils.INSTANCE.isFoldable())
Expand Down Expand Up @@ -692,6 +703,13 @@ public String getLocaleAndConfidenceInfo() {

@Override
public void onDestroy() {
// TODO LOL: i don't actually have any idea how the LatinIME lifecycle
// works. to prevent resource leaks, we need to attach the speech
// recognizer to the lifecycle of something that makes sense for what
// it is, and someone with more knowledge than me needs to make that
// decision.
mVoiceTranscriber.destroy();
mVoiceTranscriber = null;
mClipboardHistoryManager.onDestroy();
mDictionaryFacilitator.closeDictionaries();
mSettings.onDestroy();
Expand Down Expand Up @@ -1119,6 +1137,8 @@ public void onExtractedCursorMovement(final int dx, final int dy) {
@Override
public void hideWindow() {
Log.i(TAG, "hideWindow");
// TODO LOL: stop listening when the screen locks
mVoiceTranscriber.stopListening();
if (hasSuggestionStripView() && mSettings.getCurrent().mToolbarMode == ToolbarMode.EXPANDABLE)
mSuggestionStripView.setToolbarVisibility(false);
mKeyboardSwitcher.onHideWindow();
Expand Down Expand Up @@ -1412,7 +1432,14 @@ public void onCodeInput(final int codePoint, final int x, final int y, final boo
// completely replace #onCodeInput.
public void onEvent(@NonNull final Event event) {
if (KeyCode.VOICE_INPUT == event.getKeyCode()) {
mRichImm.switchToShortcutIme(this);
if (true) {
// TODO LOL: we need to choose whether to do this or that based on
// user preferences and what apps/services are available on the
// device.
mVoiceTranscriber.toggleListening();
} else {
mRichImm.switchToShortcutIme(this);
}
}
final InputTransaction completeInputTransaction =
mInputLogic.onCodeInput(mSettings.getCurrent(), event,
Expand Down