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"> - + - + - +