From 4e670a1a479b7ce62af9e1620ce4bf37840cc476 Mon Sep 17 00:00:00 2001 From: JadenJSJ Date: Wed, 9 Sep 2026 03:32:20 +0000 Subject: [PATCH] Preserve buffered audio through stop tap --- .../betterflow/AudioRecorderController.kt | 119 +++++++++++++++--- .../com/jadenjsj/betterflow/MainActivity.kt | 39 ++++++ .../com/jadenjsj/betterflow/OverlayService.kt | 62 ++++++--- .../java/com/jadenjsj/betterflow/Prefs.kt | 24 ++++ .../betterflow/XposedRemoteAuthSync.kt | 6 + .../xposed/BetterFlowXposedModule.kt | 22 +++- 6 files changed, 233 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/jadenjsj/betterflow/AudioRecorderController.kt b/app/src/main/java/com/jadenjsj/betterflow/AudioRecorderController.kt index 665671a..3830997 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/AudioRecorderController.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/AudioRecorderController.kt @@ -2,21 +2,27 @@ package com.jadenjsj.betterflow import android.media.AudioFormat import android.media.AudioRecord +import android.media.AudioTimestamp import android.media.MediaRecorder import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.abs class AudioRecorderController { private val recording = AtomicBoolean(false) private var audioRecord: AudioRecord? = null private var worker: Thread? = null private var pcm = ByteArrayOutputStream() + @Volatile private var stopping = false + @Volatile private var cutoffFrameExclusive = NO_CUTOFF_FRAME + @Volatile private var startedAtNanos = 0L + @Volatile private var deliveredFrames = 0L @Synchronized fun start(onPcmChunk: ((ByteArray) -> Unit)? = null, chunkBytes: Int = STREAM_CHUNK_BYTES) { - if (recording.get()) return + if (recording.get() || stopping) return require(chunkBytes > 0) { "chunkBytes must be > 0" } val minBuffer = AudioRecord.getMinBufferSize( SAMPLE_RATE, @@ -41,8 +47,11 @@ class AudioRecorderController { } pcm = ByteArrayOutputStream() audioRecord = record + cutoffFrameExclusive = NO_CUTOFF_FRAME + deliveredFrames = 0L recording.set(true) record.startRecording() + startedAtNanos = System.nanoTime() worker = Thread( { captureLoop(record, chunkBytes, onPcmChunk) }, "betterflow-audio", @@ -56,6 +65,7 @@ class AudioRecorderController { ) { val chunk = ByteArray(chunkBytes) var filled = 0 + var capturedFrames = 0L try { while (recording.get()) { val count = record.read( @@ -65,49 +75,124 @@ class AudioRecorderController { AudioRecord.READ_BLOCKING, ) if (count <= 0) continue - synchronized(this) { pcm.write(chunk, filled, count) } - filled += count + val cutoff = cutoffFrameExclusive + val acceptedCount = if (cutoff == NO_CUTOFF_FRAME) { + count + } else { + val framesRemaining = (cutoff - capturedFrames).coerceAtLeast(0L) + minOf(count, framesRemaining.times(SAMPLE_WIDTH_BYTES).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + } + if (acceptedCount > 0) { + synchronized(this) { pcm.write(chunk, filled, acceptedCount) } + filled += acceptedCount + capturedFrames += acceptedCount / SAMPLE_WIDTH_BYTES + deliveredFrames = capturedFrames + } if (filled == chunk.size) { onPcmChunk?.invoke(chunk.copyOf()) filled = 0 } + if (cutoff != NO_CUTOFF_FRAME && capturedFrames >= cutoff) { + recording.set(false) + } } } finally { if (filled > 0) onPcmChunk?.invoke(chunk.copyOf(filled)) } } - fun stopAndGetPcm(): ByteArray { - val record: AudioRecord? + /** + * Stops at the instant this method is called while optionally draining audio + * that Android captured before the tap but has not delivered to our read loop. + * Samples captured after the call are truncated using AudioRecord's monotonic + * frame timestamp; [drainTimeoutMs] is only how long we wait for old samples. + */ + fun stopAndGetPcm( + preservePreTapTail: Boolean = false, + drainTimeoutMs: Int = 0, + cutoffNanos: Long = System.nanoTime(), + ): ByteArray = stopInternal(preservePreTapTail, drainTimeoutMs, cutoffNanos).pcm + + private fun stopInternal( + preservePreTapTail: Boolean, + drainTimeoutMs: Int, + cutoffNanos: Long, + ): StopResult { + val record: AudioRecord val captureWorker: Thread? synchronized(this) { - if (!recording.getAndSet(false)) return pcm.toByteArray() - record = audioRecord + if (!recording.get()) return StopResult(pcm.toByteArray(), ownedRecorder = false) + record = audioRecord ?: return StopResult(pcm.toByteArray(), ownedRecorder = false) captureWorker = worker + // Claim this AudioRecord before waiting for the tail. Cancellation or + // destruction may call stop again while the first caller is draining. + audioRecord = null + worker = null + stopping = true + if (preservePreTapTail && drainTimeoutMs > 0) { + cutoffFrameExclusive = estimateCutoffFrame(record, cutoffNanos) + } else { + recording.set(false) + } } + if (preservePreTapTail && drainTimeoutMs > 0) { + // The capture loop exits as soon as it has read through the tap frame. + // This wait does not extend the transcript beyond that frame. + runCatching { captureWorker?.join(drainTimeoutMs.coerceAtLeast(1).toLong()) } + } + recording.set(false) // AudioRecord.stop() unblocks READ_BLOCKING. Do not hold this object's // monitor while joining: captureLoop needs the same monitor for its // final PCM write before it can exit. try { - record?.stop() + record.stop() } catch (_: Throwable) { } - captureWorker?.join(1200) - record?.release() + runCatching { captureWorker?.join(1200) } + runCatching { record.release() } synchronized(this) { - if (audioRecord === record) audioRecord = null - if (worker === captureWorker) worker = null - return pcm.toByteArray() + stopping = false + return StopResult(pcm.toByteArray(), ownedRecorder = true) + } + } + + private data class StopResult(val pcm: ByteArray, val ownedRecorder: Boolean) + + private fun estimateCutoffFrame(record: AudioRecord, cutoffNanos: Long): Long { + val elapsedCutoffFrame = nanosToFrames((cutoffNanos - startedAtNanos).coerceAtLeast(0L)) + val timestamp = AudioTimestamp() + val status = runCatching { + record.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC) + }.getOrDefault(AudioRecord.ERROR_INVALID_OPERATION) + if (status == AudioRecord.SUCCESS && timestamp.nanoTime > 0L) { + val elapsedNanos = (cutoffNanos - timestamp.nanoTime).coerceAtLeast(0L) + val timestampCutoffFrame = timestamp.framePosition + nanosToFrames(elapsedNanos) + // Reject devices whose frame counter does not reset for this AudioRecord. + // Already-delivered frames were necessarily captured before the tap. + if (abs(timestampCutoffFrame - elapsedCutoffFrame) <= MAX_TIMESTAMP_SKEW_FRAMES) { + return timestampCutoffFrame.coerceAtLeast(deliveredFrames) + } } + + // Some devices do not expose AudioRecord timestamps. Frame zero begins at + // startRecording(), so elapsed monotonic time is a safe cutoff fallback. + return elapsedCutoffFrame.coerceAtLeast(deliveredFrames) } + private fun nanosToFrames(nanos: Long): Long = + ((nanos.toDouble() * SAMPLE_RATE.toDouble()) / NANOS_PER_SECOND).toLong() + fun stopAndGetWav(): ByteArray = pcmToWav(stopAndGetPcm()) fun stopAndDiscard() { - stopAndGetPcm() - synchronized(this) { pcm = ByteArrayOutputStream() } + val result = stopInternal( + preservePreTapTail = false, + drainTimeoutMs = 0, + cutoffNanos = System.nanoTime(), + ) + if (result.ownedRecorder) synchronized(this) { pcm = ByteArrayOutputStream() } } fun isRecording(): Boolean = recording.get() @@ -118,6 +203,10 @@ class AudioRecorderController { const val SAMPLE_WIDTH_BYTES = 2 const val STREAM_CHUNK_MS = 100 const val STREAM_CHUNK_BYTES = SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH_BYTES * STREAM_CHUNK_MS / 1000 + private const val NO_CUTOFF_FRAME = Long.MAX_VALUE + private const val NANOS_PER_SECOND = 1_000_000_000.0 + // One 200 ms capture buffer is enough to absorb normal timestamp jitter. + private const val MAX_TIMESTAMP_SKEW_FRAMES = SAMPLE_RATE / 5L fun pcmToWav(raw: ByteArray): ByteArray { val evenSize = raw.size - (raw.size % SAMPLE_WIDTH_BYTES) diff --git a/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt b/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt index f6f0013..3aa1b4c 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/MainActivity.kt @@ -80,6 +80,8 @@ private fun SettingsScreen() { var bubbleOpacityPercent by remember { mutableStateOf(Prefs.bubbleOpacityPercent(context)) } var notificationPriority by remember { mutableStateOf(Prefs.notificationPriority(context)) } var legacyTranscription by remember { mutableStateOf(Prefs.legacyTranscription(context)) } + var preservePreTapAudio by remember { mutableStateOf(Prefs.preservePreTapAudio(context)) } + var audioDrainTimeoutMs by remember { mutableStateOf(Prefs.audioDrainTimeoutMs(context)) } var streamingKeyConfigured by remember { mutableStateOf(Prefs.streamingApiKey(context) != null || BuildConfig.WISPR_BASETEN_API_KEY.isNotBlank()) } var streamingApiKey by remember { mutableStateOf("") } var email by remember { mutableStateOf(session?.email.orEmpty()) } @@ -121,6 +123,43 @@ private fun SettingsScreen() { }, ) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text("Preserve words before stop tap") + Text( + "Drain Android's pending microphone buffer, but discard every sample captured after you tap stop.", + style = MaterialTheme.typography.bodySmall, + ) + } + Switch( + checked = preservePreTapAudio, + onCheckedChange = { enabled -> + preservePreTapAudio = enabled + Prefs.setPreservePreTapAudio(context, enabled) + status = if (enabled) "Pre-tap audio preservation enabled" else "Microphone will stop immediately" + }, + ) + } + + Text("Pre-tap buffer drain timeout: $audioDrainTimeoutMs ms") + Slider( + value = audioDrainTimeoutMs.toFloat(), + onValueChange = { audioDrainTimeoutMs = (it / 50f).roundToInt() * 50 }, + onValueChangeFinished = { + Prefs.setAudioDrainTimeoutMs(context, audioDrainTimeoutMs) + status = "Pre-tap buffer drain timeout set to $audioDrainTimeoutMs ms" + }, + valueRange = Prefs.MIN_AUDIO_DRAIN_TIMEOUT_MS.toFloat()..Prefs.MAX_AUDIO_DRAIN_TIMEOUT_MS.toFloat(), + enabled = preservePreTapAudio, + ) + Text( + "This is a maximum drain wait, not extra recorded time. The transcript cutoff remains the stop-tap timestamp.", + style = MaterialTheme.typography.bodySmall, + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt b/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt index aff0645..3e0a328 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/OverlayService.kt @@ -58,6 +58,7 @@ class OverlayService : Service() { private var legacyForCurrentRecording = false private var currentPcm = ByteArray(0) private var processingJob: Job? = null + private var captureFinalizeJob: Job? = null private var streamWorker: Job? = null @Volatile private var activeStream: WisprStreamingClient.Session? = null @Volatile private var streamingFailure: Throwable? = null @@ -106,6 +107,7 @@ class OverlayService : Service() { streamStopRequested = true activeStream?.cancel("betterFlow service destroyed") streamWorker?.cancel() + captureFinalizeJob?.cancel() processingJob?.cancel() wispr.cancelActiveTranscription() if (recorder.isRecording()) runCatching { recorder.stopAndDiscard() } @@ -493,7 +495,11 @@ class OverlayService : Service() { streamQueueEnabled.set(false) Log.w(TAG, "Wispr streaming failed: ${t.message}", t) withContext(Dispatchers.Main.immediate) { - if (generation == operationGeneration && state == BubbleState.PROCESSING) { + if ( + generation == operationGeneration && + state == BubbleState.PROCESSING && + streamCaptureFinalized + ) { startLegacyFallback(currentPcm, generation, "streaming failed: ${t.message}") } } @@ -522,30 +528,45 @@ class OverlayService : Service() { private fun stopRecording() { if (state != BubbleState.RECORDING) return + val cutoffNanos = System.nanoTime() + val generation = operationGeneration + val preservePreTapAudio = Prefs.preservePreTapAudio(this) + val drainTimeoutMs = Prefs.audioDrainTimeoutMs(this) updateState(BubbleState.PROCESSING) streamQueueEnabled.set(false) streamStopRequested = true - currentPcm = recorder.stopAndGetPcm() - streamCaptureFinalized = true - - if (currentPcm.isEmpty()) { - cancelStreamOnly("empty recording") - updateState(BubbleState.IDLE) - return - } + captureFinalizeJob?.cancel() + captureFinalizeJob = scope.launch(Dispatchers.IO) { + val captured = recorder.stopAndGetPcm( + preservePreTapTail = preservePreTapAudio, + drainTimeoutMs = drainTimeoutMs, + cutoffNanos = cutoffNanos, + ) + withContext(Dispatchers.Main.immediate) { + if (generation != operationGeneration || state != BubbleState.PROCESSING) return@withContext + currentPcm = captured + streamCaptureFinalized = true + captureFinalizeJob = null + + if (currentPcm.isEmpty()) { + cancelStreamOnly("empty recording") + updateState(BubbleState.IDLE) + return@withContext + } - if (legacyForCurrentRecording) { - startLegacyFallback(currentPcm, operationGeneration, "legacy mode") - return - } + if (legacyForCurrentRecording) { + startLegacyFallback(currentPcm, generation, "legacy mode") + return@withContext + } - streamingFailure?.let { failure -> - cancelStreamOnly("stream already failed") - startLegacyFallback(currentPcm, operationGeneration, "streaming failed: ${failure.message}") - return + streamingFailure?.let { failure -> + cancelStreamOnly("stream already failed") + startLegacyFallback(currentPcm, generation, "streaming failed: ${failure.message}") + } + // Otherwise the stream worker sends the retained PCM suffix and + // commits after capture through the stop-tap timestamp is complete. + } } - // The stream worker drains all queued PCM, sends the separate commit frame, - // waits for the final response, then calls handleStreamingResult(). } private fun handleStreamingResult(result: WisprStreamingClient.Result, generation: Long) { @@ -629,6 +650,8 @@ class OverlayService : Service() { activeStream = null streamWorker?.cancel() streamWorker = null + captureFinalizeJob?.cancel() + captureFinalizeJob = null processingJob?.cancel() processingJob = null val cancelledHttp = wispr.cancelActiveTranscription() @@ -654,6 +677,7 @@ class OverlayService : Service() { private fun finishOperation(generation: Long) { if (generation != operationGeneration) return cancelStreamOnly("operation complete") + captureFinalizeJob = null processingJob = null streamingFailure = null currentPcm = ByteArray(0) diff --git a/app/src/main/java/com/jadenjsj/betterflow/Prefs.kt b/app/src/main/java/com/jadenjsj/betterflow/Prefs.kt index c34c33d..48d26b7 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/Prefs.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/Prefs.kt @@ -49,6 +49,8 @@ object Prefs { private const val KEY_VOICE_TRIGGER_V2_MIGRATED = "voice_trigger_v2_migrated" private const val KEY_LEGACY_TRANSCRIPTION = "legacy_transcription" private const val KEY_STREAMING_API_KEY = "streaming_api_key" + private const val KEY_PRESERVE_PRE_TAP_AUDIO = "preserve_pre_tap_audio" + private const val KEY_AUDIO_DRAIN_TIMEOUT_MS = "audio_drain_timeout_ms" private fun prefs(context: Context) = context.getSharedPreferences(NAME, Context.MODE_PRIVATE) @@ -159,12 +161,34 @@ object Prefs { XposedRemoteAuthSync.syncFromLocal() } + fun preservePreTapAudio(context: Context): Boolean = + prefs(context).getBoolean(KEY_PRESERVE_PRE_TAP_AUDIO, true) + + fun setPreservePreTapAudio(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_PRESERVE_PRE_TAP_AUDIO, enabled).apply() + XposedRemoteAuthSync.syncFromLocal() + } + + fun audioDrainTimeoutMs(context: Context): Int = + prefs(context).getInt(KEY_AUDIO_DRAIN_TIMEOUT_MS, DEFAULT_AUDIO_DRAIN_TIMEOUT_MS) + .coerceIn(MIN_AUDIO_DRAIN_TIMEOUT_MS, MAX_AUDIO_DRAIN_TIMEOUT_MS) + + fun setAudioDrainTimeoutMs(context: Context, timeoutMs: Int) { + prefs(context).edit() + .putInt(KEY_AUDIO_DRAIN_TIMEOUT_MS, timeoutMs.coerceIn(MIN_AUDIO_DRAIN_TIMEOUT_MS, MAX_AUDIO_DRAIN_TIMEOUT_MS)) + .apply() + XposedRemoteAuthSync.syncFromLocal() + } + const val MIN_BUBBLE_SIZE_DP = 36 const val MAX_BUBBLE_SIZE_DP = 88 const val DEFAULT_BUBBLE_SIZE_DP = 58 const val MIN_BUBBLE_OPACITY_PERCENT = 20 const val MAX_BUBBLE_OPACITY_PERCENT = 100 const val DEFAULT_BUBBLE_OPACITY_PERCENT = 100 + const val MIN_AUDIO_DRAIN_TIMEOUT_MS = 100 + const val MAX_AUDIO_DRAIN_TIMEOUT_MS = 1000 + const val DEFAULT_AUDIO_DRAIN_TIMEOUT_MS = 400 } object VoiceRuntimeState { diff --git a/app/src/main/java/com/jadenjsj/betterflow/XposedRemoteAuthSync.kt b/app/src/main/java/com/jadenjsj/betterflow/XposedRemoteAuthSync.kt index 6ea34b2..0e313f6 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/XposedRemoteAuthSync.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/XposedRemoteAuthSync.kt @@ -14,6 +14,8 @@ object XposedRemoteAuthSync { const val KEY_STREAMING_API_KEY = "streaming_api_key" const val KEY_GBOARD_MIC_ENABLED = "gboard_mic_enabled" const val KEY_LEGACY_TRANSCRIPTION = "legacy_transcription" + const val KEY_PRESERVE_PRE_TAP_AUDIO = "preserve_pre_tap_audio" + const val KEY_AUDIO_DRAIN_TIMEOUT_MS = "audio_drain_timeout_ms" @Volatile private var appContext: Context? = null @Volatile private var service: XposedService? = null @@ -52,6 +54,8 @@ object XposedRemoteAuthSync { .remove(KEY_STREAMING_API_KEY) .remove(KEY_GBOARD_MIC_ENABLED) .remove(KEY_LEGACY_TRANSCRIPTION) + .remove(KEY_PRESERVE_PRE_TAP_AUDIO) + .remove(KEY_AUDIO_DRAIN_TIMEOUT_MS) if (session != null) { edit.putString(KEY_EMAIL, session.email) .putString(KEY_ACCESS, session.accessToken) @@ -62,6 +66,8 @@ object XposedRemoteAuthSync { Prefs.streamingApiKey(context)?.let { edit.putString(KEY_STREAMING_API_KEY, it) } edit.putBoolean(KEY_GBOARD_MIC_ENABLED, Prefs.gboardMicEnabled(context)) edit.putBoolean(KEY_LEGACY_TRANSCRIPTION, Prefs.legacyTranscription(context)) + edit.putBoolean(KEY_PRESERVE_PRE_TAP_AUDIO, Prefs.preservePreTapAudio(context)) + edit.putInt(KEY_AUDIO_DRAIN_TIMEOUT_MS, Prefs.audioDrainTimeoutMs(context)) } check(edit.commit()) { "remote preference commit returned false" } Log.i(TAG, if (session != null) "Wispr auth synced to LSPosed remote prefs" else "Wispr auth cleared from LSPosed remote prefs") diff --git a/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt b/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt index ac77390..e945497 100644 --- a/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt +++ b/app/src/main/java/com/jadenjsj/betterflow/xposed/BetterFlowXposedModule.kt @@ -28,6 +28,7 @@ import com.jadenjsj.betterflow.AudioRecorderController import com.jadenjsj.betterflow.AuthStore import com.jadenjsj.betterflow.BuildConfig import com.jadenjsj.betterflow.InputInjector +import com.jadenjsj.betterflow.Prefs import com.jadenjsj.betterflow.WisprClient import com.jadenjsj.betterflow.WisprSession import com.jadenjsj.betterflow.WisprSessionStore @@ -382,6 +383,8 @@ class BetterFlowXposedModule( val wispr: WisprClient, val streaming: WisprStreamingClient, val legacyOnly: Boolean, + val preservePreTapAudio: Boolean, + val audioDrainTimeoutMs: Int, ) private class HookSessionStore(private val prefs: SharedPreferences) : WisprSessionStore { @@ -439,6 +442,11 @@ class BetterFlowXposedModule( wispr = wispr, streaming = WisprStreamingClient(wispr) { apiKey }, legacyOnly = prefs.getBoolean(REMOTE_KEY_LEGACY_TRANSCRIPTION, false), + preservePreTapAudio = prefs.getBoolean(REMOTE_KEY_PRESERVE_PRE_TAP_AUDIO, true), + audioDrainTimeoutMs = prefs.getInt( + REMOTE_KEY_AUDIO_DRAIN_TIMEOUT_MS, + Prefs.DEFAULT_AUDIO_DRAIN_TIMEOUT_MS, + ).coerceIn(Prefs.MIN_AUDIO_DRAIN_TIMEOUT_MS, Prefs.MAX_AUDIO_DRAIN_TIMEOUT_MS), ) } @@ -523,6 +531,7 @@ class BetterFlowXposedModule( private fun stopHookRecordingAndTranscribe(service: InputMethodService) { if (!hookOwnsVoiceSession || voiceState != VoiceState.RECORDING) return + val cutoffNanos = System.nanoTime() val generation = hookOperationGeneration val clients = hookClients ?: run { cancelHookVoice("missing hook clients") @@ -530,16 +539,17 @@ class BetterFlowXposedModule( } voiceState = VoiceState.PROCESSING applyVoiceStateVisual(service, voiceState) - val pcm = hookRecorder.stopAndGetPcm() - if (pcm.isEmpty()) { - finishHookVoice(service, generation, null, IllegalStateException("no microphone audio captured")) - return - } Thread({ var text: String? = null var error: Throwable? = null try { + val pcm = hookRecorder.stopAndGetPcm( + preservePreTapTail = clients.preservePreTapAudio, + drainTimeoutMs = clients.audioDrainTimeoutMs, + cutoffNanos = cutoffNanos, + ) + check(pcm.isNotEmpty()) { "no microphone audio captured" } text = if (clients.legacyOnly) { runBlocking { clients.wispr.transcribeLegacyPcm(pcm) }.trim() } else { @@ -1136,6 +1146,8 @@ class BetterFlowXposedModule( private const val REMOTE_KEY_STREAMING_API_KEY = "streaming_api_key" private const val REMOTE_KEY_GBOARD_MIC_ENABLED = "gboard_mic_enabled" private const val REMOTE_KEY_LEGACY_TRANSCRIPTION = "legacy_transcription" + private const val REMOTE_KEY_PRESERVE_PRE_TAP_AUDIO = "preserve_pre_tap_audio" + private const val REMOTE_KEY_AUDIO_DRAIN_TIMEOUT_MS = "audio_drain_timeout_ms" private const val HOOK_STREAM_OPEN_TIMEOUT_MS = 20_000L private const val HOOK_STREAM_RESULT_TIMEOUT_MS = 30_000L private const val COMMIT_DEDUPE_TTL_MS = 10_000L