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