diff --git a/android/app/src/main/java/io/github/transui/android/AmplitudeView.java b/android/app/src/main/java/io/github/transui/android/AmplitudeView.java
new file mode 100644
index 0000000..012622b
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/AmplitudeView.java
@@ -0,0 +1,50 @@
+package io.github.transui.android;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import java.util.Arrays;
+
+public final class AmplitudeView extends View {
+ private static final int LEVEL_COUNT = 22;
+
+ private final float[] levels = new float[LEVEL_COUNT];
+ private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ public AmplitudeView(Context context, AttributeSet attributes) {
+ super(context, attributes);
+ paint.setColor(context.getColor(R.color.signal));
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ }
+
+ void setAmplitude(float amplitude) {
+ System.arraycopy(levels, 1, levels, 0, levels.length - 1);
+ levels[levels.length - 1] = Math.max(0, Math.min(1, amplitude));
+ invalidate();
+ }
+
+ void reset() {
+ Arrays.fill(levels, 0);
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ float availableWidth = getWidth() - getPaddingLeft() - getPaddingRight();
+ float gap = availableWidth / (LEVEL_COUNT * 2 - 1);
+ float strokeWidth = Math.max(2, gap);
+ float centerY = getHeight() / 2f;
+ float maximumHeight = Math.max(0, getHeight() - getPaddingTop() - getPaddingBottom());
+ paint.setStrokeWidth(strokeWidth);
+
+ for (int index = 0; index < levels.length; index++) {
+ float height = Math.max(strokeWidth, levels[index] * maximumHeight);
+ float x = getPaddingLeft() + strokeWidth / 2 + index * gap * 2;
+ canvas.drawLine(x, centerY - height / 2, x, centerY + height / 2, paint);
+ }
+ }
+}
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
index b164b6b..ef0e047 100644
--- a/android/app/src/main/java/io/github/transui/android/PushToTalkButton.java
+++ b/android/app/src/main/java/io/github/transui/android/PushToTalkButton.java
@@ -3,9 +3,9 @@ package io.github.transui.android;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
-import android.widget.Button;
+import android.widget.ImageButton;
-public final class PushToTalkButton extends Button {
+public final class PushToTalkButton extends ImageButton {
private Runnable pressedAction;
private Runnable releasedAction;
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
index f89d95f..5cf6a90 100644
--- a/android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java
+++ b/android/app/src/main/java/io/github/transui/android/SpeechRecognitionActivity.java
@@ -11,7 +11,6 @@ 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;
@@ -32,6 +31,7 @@ public final class SpeechRecognitionActivity extends Activity {
private TextView status;
private TextView elapsed;
+ private AmplitudeView amplitude;
private PushToTalkButton record;
private AppConfig config;
private VoiceRecordingController controller;
@@ -46,6 +46,7 @@ public final class SpeechRecognitionActivity extends Activity {
setContentView(R.layout.recognition_panel);
status = findViewById(R.id.recognition_status);
elapsed = findViewById(R.id.recognition_elapsed);
+ amplitude = findViewById(R.id.recognition_amplitude);
record = findViewById(R.id.recognition_record);
findViewById(R.id.recognition_cancel).setOnClickListener(view -> cancelAndFinish());
@@ -77,11 +78,14 @@ public final class SpeechRecognitionActivity extends Activity {
}
private void configureRecordButton() {
+ record.setAlpha(1);
if (config.pushToTalk) {
- record.setText(R.string.hold_to_talk);
+ record.setImageResource(R.drawable.ic_mic);
+ record.setContentDescription(getString(R.string.hold_to_talk));
record.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
- record.setText(R.string.stop);
+ record.setImageResource(R.drawable.ic_stop);
+ record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
@@ -112,13 +116,17 @@ public final class SpeechRecognitionActivity extends Activity {
private void stopRecording() {
if (!sessionActive || controller == null) return;
record.setEnabled(false);
+ record.setAlpha(0.4f);
+ amplitude.reset();
controller.stop();
}
private void showUnavailable(int message) {
status.setText(message);
elapsed.setText("");
+ amplitude.reset();
record.setEnabled(false);
+ record.setAlpha(0.4f);
}
private void cancelAndFinish() {
@@ -173,18 +181,26 @@ public final class SpeechRecognitionActivity extends Activity {
public void onRecording() {
runOnUiThread(() -> {
status.setText(R.string.listening);
+ record.setImageResource(R.drawable.ic_stop);
recordingStartedAt = SystemClock.elapsedRealtime();
elapsed.setText(R.string.elapsed_zero);
handler.post(updateElapsed);
});
}
+ @Override
+ public void onAmplitude(float level) {
+ runOnUiThread(() -> amplitude.setAmplitude(level));
+ }
+
@Override
public void onTranscribing() {
runOnUiThread(() -> {
handler.removeCallbacks(updateElapsed);
status.setText(R.string.transcribing);
+ amplitude.reset();
record.setEnabled(false);
+ record.setAlpha(0.4f);
});
}
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
index bc181cb..9562ad7 100644
--- a/android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java
+++ b/android/app/src/main/java/io/github/transui/android/TransuiInputMethodService.java
@@ -9,7 +9,6 @@ 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;
@@ -17,6 +16,7 @@ public final class TransuiInputMethodService extends InputMethodService {
private final Handler handler = new Handler(Looper.getMainLooper());
private TextView status;
+ private AmplitudeView amplitude;
private PushToTalkButton record;
private AppConfig config;
private VoiceRecordingController controller;
@@ -27,6 +27,7 @@ public final class TransuiInputMethodService extends InputMethodService {
FrameLayout parent = new FrameLayout(this);
View view = getLayoutInflater().inflate(R.layout.voice_input, parent, false);
status = view.findViewById(R.id.voice_status);
+ amplitude = view.findViewById(R.id.voice_amplitude);
record = view.findViewById(R.id.voice_record);
view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
return view;
@@ -63,13 +64,16 @@ public final class TransuiInputMethodService extends InputMethodService {
private void configureRecordButton() {
record.setEnabled(true);
+ record.setAlpha(1);
record.setOnClickListener(null);
record.setPushToTalkActions(null, null);
if (config.pushToTalk) {
- record.setText(R.string.hold_to_talk);
+ record.setImageResource(R.drawable.ic_mic);
+ record.setContentDescription(getString(R.string.hold_to_talk));
record.setPushToTalkActions(this::startRecording, this::stopRecording);
} else {
- record.setText(R.string.stop);
+ record.setImageResource(R.drawable.ic_stop);
+ record.setContentDescription(getString(R.string.stop));
record.setOnClickListener(view -> stopRecording());
}
}
@@ -84,18 +88,24 @@ public final class TransuiInputMethodService extends InputMethodService {
private void stopRecording() {
if (!sessionActive || controller == null) return;
record.setEnabled(false);
+ record.setAlpha(0.4f);
+ amplitude.reset();
controller.stop();
}
private void cancelSession() {
+ handler.removeCallbacksAndMessages(null);
if (controller != null) controller.cancel();
controller = null;
sessionActive = false;
+ if (amplitude != null) amplitude.reset();
}
private void showUnavailable(int message) {
status.setText(message);
+ amplitude.reset();
record.setEnabled(false);
+ record.setAlpha(0.4f);
}
private void returnToKeyboard() {
@@ -110,14 +120,24 @@ public final class TransuiInputMethodService extends InputMethodService {
private final class ImeListener implements VoiceRecordingController.Listener {
@Override
public void onRecording() {
- handler.post(() -> status.setText(R.string.listening));
+ handler.post(() -> {
+ status.setText(R.string.listening);
+ record.setImageResource(R.drawable.ic_stop);
+ });
+ }
+
+ @Override
+ public void onAmplitude(float level) {
+ handler.post(() -> amplitude.setAmplitude(level));
}
@Override
public void onTranscribing() {
handler.post(() -> {
status.setText(R.string.transcribing);
+ amplitude.reset();
record.setEnabled(false);
+ record.setAlpha(0.4f);
});
}
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
index f960825..c31b077 100644
--- a/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java
+++ b/android/app/src/main/java/io/github/transui/android/VoiceRecordingController.java
@@ -19,6 +19,8 @@ final class VoiceRecordingController {
interface Listener {
void onRecording();
+ void onAmplitude(float amplitude);
+
void onTranscribing();
void onResult(String text);
@@ -126,6 +128,7 @@ final class VoiceRecordingController {
return null;
}
appendSamples(pcm, samples, count);
+ listener.onAmplitude(calculateAmplitude(samples, count));
if (stopRequested.get()) break;
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
}
@@ -155,4 +158,16 @@ final class VoiceRecordingController {
output.write((sample >>> 8) & 0xff);
}
}
+
+ private static float calculateAmplitude(short[] samples, int count) {
+ double squaredTotal = 0;
+ for (int index = 0; index < count; index++) {
+ double normalized = samples[index] / (double) Short.MAX_VALUE;
+ squaredTotal += normalized * normalized;
+ }
+ double rootMeanSquare = Math.sqrt(squaredTotal / count);
+ if (rootMeanSquare <= 0) return 0;
+ double decibels = 20 * Math.log10(rootMeanSquare);
+ return (float) Math.max(0, Math.min(1, (decibels + 50) / 50));
+ }
}
diff --git a/android/app/src/main/res/drawable/ic_keyboard.xml b/android/app/src/main/res/drawable/ic_keyboard.xml
new file mode 100644
index 0000000..6ac88b9
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_keyboard.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_mic.xml b/android/app/src/main/res/drawable/ic_mic.xml
new file mode 100644
index 0000000..c0e8e76
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_mic.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_stop.xml b/android/app/src/main/res/drawable/ic_stop.xml
new file mode 100644
index 0000000..8af5ca0
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_stop.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/voice_primary_button.xml b/android/app/src/main/res/drawable/voice_primary_button.xml
new file mode 100644
index 0000000..b128f39
--- /dev/null
+++ b/android/app/src/main/res/drawable/voice_primary_button.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/voice_secondary_button.xml b/android/app/src/main/res/drawable/voice_secondary_button.xml
new file mode 100644
index 0000000..6a50358
--- /dev/null
+++ b/android/app/src/main/res/drawable/voice_secondary_button.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/recognition_panel.xml b/android/app/src/main/res/layout/recognition_panel.xml
index 18887ea..527994f 100644
--- a/android/app/src/main/res/layout/recognition_panel.xml
+++ b/android/app/src/main/res/layout/recognition_panel.xml
@@ -41,12 +41,28 @@
android:textColor="@color/slate"
android:textSize="14sp" />
+
+
+ android:layout_width="56dp"
+ android:layout_height="56dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="14dp"
+ android:background="@drawable/voice_primary_button"
+ android:contentDescription="@string/stop"
+ android:padding="16dp"
+ android:src="@drawable/ic_stop"
+ android:tint="@color/paper" />