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
index 012622b..6489554 100644
--- a/android/app/src/main/java/io/github/transui/android/AmplitudeView.java
+++ b/android/app/src/main/java/io/github/transui/android/AmplitudeView.java
@@ -2,49 +2,131 @@ package io.github.transui.android;
import android.content.Context;
import android.graphics.Canvas;
+import android.graphics.LinearGradient;
+import android.graphics.Matrix;
import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Shader;
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 static final int LEVEL_COUNT = 192;
private final float[] levels = new float[LEVEL_COUNT];
- private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint edgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path waveform = new Path();
+ private final LinearGradient fillGradient;
+ private final Matrix gradientMatrix = new Matrix();
+ private final float sampleSpacing;
+ private final float minimumHalfHeight;
+ private final int signalColor;
+ private float previousLevel;
public AmplitudeView(Context context, AttributeSet attributes) {
super(context, attributes);
- paint.setColor(context.getColor(R.color.signal));
- paint.setStrokeCap(Paint.Cap.ROUND);
+ float density = getResources().getDisplayMetrics().density;
+ sampleSpacing = 5 * density;
+ minimumHalfHeight = 1.5f * density;
+ signalColor = context.getColor(R.color.signal);
+
+ fillPaint.setStyle(Paint.Style.FILL);
+ int transparentSignal = signalColor & 0x00ffffff;
+ fillGradient = new LinearGradient(
+ 0, 0, 0, 1,
+ new int[]{transparentSignal, signalColor, transparentSignal},
+ new float[]{0, 0.5f, 1}, Shader.TileMode.CLAMP);
+ fillPaint.setShader(fillGradient);
+ edgePaint.setColor(signalColor);
+ edgePaint.setAlpha(210);
+ edgePaint.setStyle(Paint.Style.STROKE);
+ edgePaint.setStrokeWidth(1.5f * density);
+ edgePaint.setStrokeJoin(Paint.Join.ROUND);
+ centerPaint.setColor(signalColor);
+ centerPaint.setAlpha(70);
+ centerPaint.setStrokeWidth(density);
}
void setAmplitude(float amplitude) {
+ float level = Math.max(0, Math.min(1, amplitude));
+ level = Math.max(level, previousLevel * 0.58f);
System.arraycopy(levels, 1, levels, 0, levels.length - 1);
- levels[levels.length - 1] = Math.max(0, Math.min(1, amplitude));
+ levels[levels.length - 1] = level;
+ previousLevel = level;
invalidate();
}
void reset() {
Arrays.fill(levels, 0);
+ previousLevel = 0;
invalidate();
}
+ @Override
+ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight);
+ float top = getPaddingTop();
+ float contentHeight = Math.max(1, height - getPaddingTop() - getPaddingBottom());
+ gradientMatrix.setScale(1, contentHeight);
+ gradientMatrix.postTranslate(0, top);
+ fillGradient.setLocalMatrix(gradientMatrix);
+ }
+
@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 availableWidth = Math.max(0, getWidth() - getPaddingLeft() - getPaddingRight());
+ int visibleCount = Math.min(LEVEL_COUNT,
+ Math.max(2, (int) (availableWidth / sampleSpacing) + 1));
+ float step = availableWidth / (visibleCount - 1);
+ float startX = getPaddingLeft();
float centerY = getHeight() / 2f;
- float maximumHeight = Math.max(0, getHeight() - getPaddingTop() - getPaddingBottom());
- paint.setStrokeWidth(strokeWidth);
+ float maximumHalfHeight = Math.max(minimumHalfHeight,
+ (getHeight() - getPaddingTop() - getPaddingBottom()) / 2f);
+ int firstLevel = LEVEL_COUNT - visibleCount;
- 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);
+ canvas.drawLine(startX, centerY, startX + availableWidth, centerY, centerPaint);
+ waveform.reset();
+
+ float previousX = startX;
+ float previousY = upperY(firstLevel, centerY, maximumHalfHeight);
+ waveform.moveTo(previousX, previousY);
+ for (int index = 1; index < visibleCount; index++) {
+ float x = startX + index * step;
+ float y = upperY(firstLevel + index, centerY, maximumHalfHeight);
+ waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
+ previousX = x;
+ previousY = y;
}
+ waveform.lineTo(previousX, previousY);
+
+ previousY = lowerY(firstLevel + visibleCount - 1, centerY, maximumHalfHeight);
+ waveform.lineTo(previousX, previousY);
+ for (int index = visibleCount - 2; index >= 0; index--) {
+ float x = startX + index * step;
+ float y = lowerY(firstLevel + index, centerY, maximumHalfHeight);
+ waveform.quadTo(previousX, previousY, (previousX + x) / 2, (previousY + y) / 2);
+ previousX = x;
+ previousY = y;
+ }
+ waveform.lineTo(previousX, previousY);
+ waveform.close();
+
+ canvas.drawPath(waveform, fillPaint);
+ canvas.drawPath(waveform, edgePaint);
+ }
+
+ private float upperY(int index, float centerY, float maximumHalfHeight) {
+ return centerY - minimumHalfHeight
+ - levels[index] * (maximumHalfHeight - minimumHalfHeight);
+ }
+
+ private float lowerY(int index, float centerY, float maximumHalfHeight) {
+ return centerY + minimumHalfHeight
+ + levels[index] * (maximumHalfHeight - minimumHalfHeight);
}
}
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 ef0e047..a24542c 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
@@ -8,6 +8,7 @@ import android.widget.ImageButton;
public final class PushToTalkButton extends ImageButton {
private Runnable pressedAction;
private Runnable releasedAction;
+ private boolean trackingPress;
public PushToTalkButton(Context context, AttributeSet attributes) {
super(context, attributes);
@@ -16,25 +17,35 @@ public final class PushToTalkButton extends ImageButton {
void setPushToTalkActions(Runnable pressedAction, Runnable releasedAction) {
this.pressedAction = pressedAction;
this.releasedAction = releasedAction;
+ trackingPress = false;
+ setPressed(false);
+ setScaleX(1);
+ setScaleY(1);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (pressedAction == null || releasedAction == null) return super.onTouchEvent(event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ trackingPress = true;
pressedAction.run();
setPressed(true);
+ animate().scaleX(0.92f).scaleY(0.92f).setDuration(90).start();
return true;
}
if (event.getAction() == MotionEvent.ACTION_UP) {
- releasedAction.run();
+ if (trackingPress) releasedAction.run();
+ trackingPress = false;
setPressed(false);
+ animate().scaleX(1).scaleY(1).setDuration(120).start();
super.performClick();
return true;
}
if (event.getAction() == MotionEvent.ACTION_CANCEL) {
- releasedAction.run();
+ if (trackingPress) releasedAction.run();
+ trackingPress = false;
setPressed(false);
+ animate().scaleX(1).scaleY(1).setDuration(120).start();
return true;
}
return true;
@@ -42,10 +53,6 @@ public final class PushToTalkButton extends ImageButton {
@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
index 5cf6a90..d001519 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,6 +11,7 @@ import android.os.Looper;
import android.os.SystemClock;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
+import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
@@ -79,6 +80,7 @@ public final class SpeechRecognitionActivity extends Activity {
private void configureRecordButton() {
record.setAlpha(1);
+ resizeRecordButton(config.pushToTalk ? 80 : 64, config.pushToTalk ? 23 : 19);
if (config.pushToTalk) {
record.setImageResource(R.drawable.ic_mic);
record.setContentDescription(getString(R.string.hold_to_talk));
@@ -90,6 +92,17 @@ public final class SpeechRecognitionActivity extends Activity {
}
}
+ private void resizeRecordButton(int sizeDp, int paddingDp) {
+ float density = getResources().getDisplayMetrics().density;
+ int size = Math.round(sizeDp * density);
+ int padding = Math.round(paddingDp * density);
+ ViewGroup.LayoutParams parameters = record.getLayoutParams();
+ parameters.width = size;
+ parameters.height = size;
+ record.setLayoutParams(parameters);
+ record.setPadding(padding, padding, padding, padding);
+ }
+
private void ensurePermissionAndStart() {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
prepareForInput();
@@ -181,7 +194,7 @@ public final class SpeechRecognitionActivity extends Activity {
public void onRecording() {
runOnUiThread(() -> {
status.setText(R.string.listening);
- record.setImageResource(R.drawable.ic_stop);
+ if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
recordingStartedAt = SystemClock.elapsedRealtime();
elapsed.setText(R.string.elapsed_zero);
handler.post(updateElapsed);
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 9562ad7..e38c730 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
@@ -8,6 +8,7 @@ import android.os.Handler;
import android.os.Looper;
import android.speech.SpeechRecognizer;
import android.view.View;
+import android.view.ViewGroup;
import android.view.inputmethod.InputConnection;
import android.widget.FrameLayout;
import android.widget.TextView;
@@ -22,6 +23,13 @@ public final class TransuiInputMethodService extends InputMethodService {
private VoiceRecordingController controller;
private boolean sessionActive;
+ @Override
+ public boolean onEvaluateFullscreenMode() {
+ // Voice input should stay attached to the calling app in landscape. Android's
+ // default extract editor is the large, blank text area shown above the IME.
+ return false;
+ }
+
@Override
public View onCreateInputView() {
FrameLayout parent = new FrameLayout(this);
@@ -67,6 +75,7 @@ public final class TransuiInputMethodService extends InputMethodService {
record.setAlpha(1);
record.setOnClickListener(null);
record.setPushToTalkActions(null, null);
+ resizeRecordButton(config.pushToTalk ? 76 : 64, config.pushToTalk ? 22 : 19);
if (config.pushToTalk) {
record.setImageResource(R.drawable.ic_mic);
record.setContentDescription(getString(R.string.hold_to_talk));
@@ -78,6 +87,17 @@ public final class TransuiInputMethodService extends InputMethodService {
}
}
+ private void resizeRecordButton(int sizeDp, int paddingDp) {
+ float density = getResources().getDisplayMetrics().density;
+ int size = Math.round(sizeDp * density);
+ int padding = Math.round(paddingDp * density);
+ ViewGroup.LayoutParams parameters = record.getLayoutParams();
+ parameters.width = size;
+ parameters.height = size;
+ record.setLayoutParams(parameters);
+ record.setPadding(padding, padding, padding, padding);
+ }
+
private void startRecording() {
if (sessionActive) return;
sessionActive = true;
@@ -122,7 +142,7 @@ public final class TransuiInputMethodService extends InputMethodService {
public void onRecording() {
handler.post(() -> {
status.setText(R.string.listening);
- record.setImageResource(R.drawable.ic_stop);
+ if (!config.pushToTalk) record.setImageResource(R.drawable.ic_stop);
});
}
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 c31b077..e17d82c 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
@@ -116,7 +116,8 @@ final class VoiceRecordingController {
}
ByteArrayOutputStream pcm = new ByteArrayOutputStream(SAMPLE_RATE * 8);
- short[] samples = new short[bufferBytes / 2];
+ // Keep meter updates quick even when AudioRecord asks for a large internal buffer.
+ short[] samples = new short[Math.min(bufferBytes / 2, 1024)];
long startedAt = SystemClock.elapsedRealtime();
try {
recorder.startRecording();
@@ -168,6 +169,7 @@ final class VoiceRecordingController {
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));
+ // Discard the microphone noise floor and reserve the top of the meter for loud speech.
+ return (float) Math.max(0, Math.min(1, (decibels + 48) / 38));
}
}
diff --git a/android/app/src/main/res/layout/voice_input.xml b/android/app/src/main/res/layout/voice_input.xml
index 1e82e03..c6fb63e 100644
--- a/android/app/src/main/res/layout/voice_input.xml
+++ b/android/app/src/main/res/layout/voice_input.xml
@@ -17,66 +17,66 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
- android:paddingStart="20dp"
- android:paddingTop="12dp"
- android:paddingEnd="20dp">
+ android:paddingStart="18dp"
+ android:paddingTop="10dp"
+ android:paddingEnd="18dp">
-
+
-
+
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:paddingBottom="10dp">
-
+
-
+
-
+