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 6489554..e300179 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
@@ -53,6 +53,7 @@ public final class AmplitudeView extends View {
void setAmplitude(float amplitude) {
float level = Math.max(0, Math.min(1, amplitude));
+ level = (float) Math.sqrt(level);
level = Math.max(level, previousLevel * 0.58f);
System.arraycopy(levels, 1, levels, 0, levels.length - 1);
levels[levels.length - 1] = level;
diff --git a/android/app/src/main/java/io/github/transui/android/AudioLevelMeter.java b/android/app/src/main/java/io/github/transui/android/AudioLevelMeter.java
new file mode 100644
index 0000000..154fea7
--- /dev/null
+++ b/android/app/src/main/java/io/github/transui/android/AudioLevelMeter.java
@@ -0,0 +1,31 @@
+package io.github.transui.android;
+
+final class AudioLevelMeter {
+ private double noiseFloorDecibels = Double.NaN;
+
+ float measure(short[] samples, int count) {
+ double squaredTotal = 0;
+ double peak = 0;
+ for (int index = 0; index < count; index++) {
+ double normalized = samples[index] / (double) Short.MAX_VALUE;
+ squaredTotal += normalized * normalized;
+ peak = Math.max(peak, Math.abs(normalized));
+ }
+ double rootMeanSquare = Math.sqrt(squaredTotal / count);
+ double effectiveLevel = Math.max(rootMeanSquare, peak * 0.12);
+ if (effectiveLevel <= 0) return 0;
+
+ double decibels = 20 * Math.log10(effectiveLevel);
+ if (Double.isNaN(noiseFloorDecibels)) {
+ noiseFloorDecibels = decibels;
+ } else if (decibels < noiseFloorDecibels + 3) {
+ noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.05;
+ } else {
+ noiseFloorDecibels += (decibels - noiseFloorDecibels) * 0.001;
+ }
+
+ double relative = (decibels - noiseFloorDecibels - 1.5) / 22;
+ double calibratedFallback = ((decibels + 72) / 54) * 0.35;
+ return (float) Math.max(0, Math.min(1, Math.max(relative, calibratedFallback)));
+ }
+}
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 e38c730..68a8856 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,6 +9,7 @@ import android.os.Looper;
import android.speech.SpeechRecognizer;
import android.view.View;
import android.view.ViewGroup;
+import android.view.WindowInsets;
import android.view.inputmethod.InputConnection;
import android.widget.FrameLayout;
import android.widget.TextView;
@@ -38,9 +39,42 @@ public final class TransuiInputMethodService extends InputMethodService {
amplitude = view.findViewById(R.id.voice_amplitude);
record = view.findViewById(R.id.voice_record);
view.findViewById(R.id.voice_keyboard).setOnClickListener(button -> returnToKeyboard());
+ applySystemBarInsets(view);
return view;
}
+ private void applySystemBarInsets(View inputView) {
+ int originalLeft = inputView.getPaddingLeft();
+ int originalTop = inputView.getPaddingTop();
+ int originalRight = inputView.getPaddingRight();
+ int originalBottom = inputView.getPaddingBottom();
+ inputView.setOnApplyWindowInsetsListener((view, insets) -> {
+ int left;
+ int right;
+ int bottom;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ android.graphics.Insets navigation = insets.getInsets(
+ WindowInsets.Type.navigationBars());
+ android.graphics.Insets gestures = insets.getInsets(
+ WindowInsets.Type.mandatorySystemGestures());
+ left = Math.max(navigation.left, gestures.left);
+ right = Math.max(navigation.right, gestures.right);
+ bottom = Math.max(navigation.bottom, gestures.bottom);
+ } else {
+ left = insets.getSystemWindowInsetLeft();
+ right = insets.getSystemWindowInsetRight();
+ bottom = insets.getSystemWindowInsetBottom();
+ }
+ view.setPadding(
+ originalLeft + left,
+ originalTop,
+ originalRight + right,
+ originalBottom + bottom);
+ return insets;
+ });
+ inputView.post(inputView::requestApplyInsets);
+ }
+
@Override
public void onStartInputView(android.view.inputmethod.EditorInfo info, boolean restarting) {
super.onStartInputView(info, restarting);
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 e17d82c..e179349 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
@@ -39,6 +39,7 @@ final class VoiceRecordingController {
private final AtomicBoolean cancelled = new AtomicBoolean();
private final AtomicBoolean started = new AtomicBoolean();
private final TranscriptionClient client = new TranscriptionClient();
+ private final AudioLevelMeter levelMeter = new AudioLevelMeter();
VoiceRecordingController(Context context, AppConfig config, Listener listener) {
this.context = context.getApplicationContext();
@@ -129,7 +130,7 @@ final class VoiceRecordingController {
return null;
}
appendSamples(pcm, samples, count);
- listener.onAmplitude(calculateAmplitude(samples, count));
+ listener.onAmplitude(levelMeter.measure(samples, count));
if (stopRequested.get()) break;
if (SystemClock.elapsedRealtime() - startedAt >= MAX_RECORDING_MS) break;
}
@@ -160,16 +161,4 @@ final class VoiceRecordingController {
}
}
- 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);
- // 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 c6fb63e..da04416 100644
--- a/android/app/src/main/res/layout/voice_input.xml
+++ b/android/app/src/main/res/layout/voice_input.xml
@@ -39,11 +39,20 @@
android:textStyle="bold" />
-
+
+
@@ -52,31 +61,22 @@
android:id="@+id/voice_keyboard"
android:layout_width="52dp"
android:layout_height="52dp"
+ android:layout_gravity="start|center_vertical"
android:background="@drawable/voice_secondary_button"
android:contentDescription="@string/keyboard"
android:padding="13dp"
android:src="@drawable/ic_keyboard"
android:tint="@color/ink" />
-
-
-
+
diff --git a/android/app/src/test/java/io/github/transui/android/AudioLevelMeterTest.java b/android/app/src/test/java/io/github/transui/android/AudioLevelMeterTest.java
new file mode 100644
index 0000000..9faa716
--- /dev/null
+++ b/android/app/src/test/java/io/github/transui/android/AudioLevelMeterTest.java
@@ -0,0 +1,35 @@
+package io.github.transui.android;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public final class AudioLevelMeterTest {
+ @Test
+ public void silenceStaysAtZero() {
+ AudioLevelMeter meter = new AudioLevelMeter();
+
+ assertEquals(0, meter.measure(new short[1024], 1024), 0);
+ }
+
+ @Test
+ public void speechRisesAboveQuietMicrophoneInput() {
+ AudioLevelMeter meter = new AudioLevelMeter();
+ short[] quiet = alternatingSamples(8);
+ short[] speech = alternatingSamples(1200);
+
+ float quietLevel = meter.measure(quiet, quiet.length);
+ float speechLevel = meter.measure(speech, speech.length);
+
+ assertTrue(speechLevel > quietLevel + 0.5f);
+ }
+
+ private static short[] alternatingSamples(int amplitude) {
+ short[] samples = new short[1024];
+ for (int index = 0; index < samples.length; index++) {
+ samples[index] = (short) (index % 2 == 0 ? amplitude : -amplitude);
+ }
+ return samples;
+ }
+}