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; + } +}