From 249fee07980a2b5bd48f935f3f2a0dddcb81a740 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 2 Sep 2026 16:51:00 -0500 Subject: [PATCH 1/4] fix(speech-to-text): resolve LlmInferenceService from SharedServices (ADFA-5403) context.services is a per-plugin registry that never holds it, so Voice-to-Code always inserted the raw transcript. Resolve per use, bound generation with a timeout, strip markdown fences, and target the open file's language, not Kotlin. --- .../plugins/stt/SpeechToTextPlugin.kt | 163 ++++++++++++++---- 1 file changed, 125 insertions(+), 38 deletions(-) diff --git a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt index 804afd67..5c6f452a 100644 --- a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt +++ b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt @@ -11,7 +11,6 @@ import android.os.Looper import android.speech.RecognitionListener import android.speech.RecognizerIntent import android.speech.SpeechRecognizer -import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat import com.itsaky.androidide.plugins.IPlugin @@ -25,13 +24,14 @@ import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.services.IdeEditorService import com.itsaky.androidide.plugins.services.IdeUIService import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.SharedServices import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - -private const val TAG = "SpeechToTextPlugin" +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException /** * Speech-to-Text Plugin provides voice-to-code capabilities. @@ -79,40 +79,65 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { override fun initialize(context: PluginContext): Boolean { this.context = context - Log.i(TAG, "SpeechToTextPlugin initialized") + context.logger.info("Plugin initialized") return true } override fun activate(): Boolean { - Log.i(TAG, "SpeechToTextPlugin activating...") - // Get services from plugin context - llmService = context.services.get(LlmInferenceService::class.java) editorService = context.services.get(IdeEditorService::class.java) uiService = context.services.get(IdeUIService::class.java) - if (llmService == null) { - Log.w(TAG, "LlmInferenceService not available - voice generation disabled") - } + // AI Core loads in parallel with us, so a miss here is retried on every use. + resolveLlmService() + if (editorService == null) { - Log.w(TAG, "IdeEditorService not available - editor integration disabled") + context.logger.warn("IdeEditorService not available - editor integration disabled") } if (uiService == null) { - Log.w(TAG, "IdeUIService not available - toolbar icon will not animate between states") + context.logger.warn("IdeUIService not available - toolbar icon will not animate between states") } - Log.i(TAG, "SpeechToTextPlugin activated") + context.logger.info("Plugin activated") return true } + /** + * Resolves AI Core's router, caching only a successful lookup so a later call retries. + * AI Core publishes it to the process-global [SharedServices]; the plugin-local registry + * is a fallback for hosts that bridge it there instead. + * + * @return the service, or null while AI Core is absent or has not activated yet + */ + private fun resolveLlmService(): LlmInferenceService? { + llmService?.let { return it } + + val service = try { + SharedServices.get(LlmInferenceService::class.java) + ?: context.services.get(LlmInferenceService::class.java) + } catch (e: Exception) { + context.logger.warn("Error resolving LlmInferenceService", e) + null + } + + if (service == null) { + context.logger.info("LlmInferenceService not available yet - install/activate the AI Core plugin") + return null + } + + llmService = service + context.logger.info("LlmInferenceService resolved - voice generation enabled") + return service + } + override fun deactivate(): Boolean { - Log.i(TAG, "SpeechToTextPlugin deactivating") + context.logger.info("Plugin deactivating") destroyRecognizer() return true } override fun dispose() { - Log.i(TAG, "SpeechToTextPlugin disposed") + context.logger.info("Plugin disposed") destroyRecognizer() // Tear down the transcript-processing scope so no LLM/generation coroutine // outlives the plugin after unload. @@ -221,7 +246,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { recognizer.startListening(intent) setState(RecordingState.RECORDING) } catch (e: Exception) { - Log.e(TAG, "Failed to start speech recognition", e) + context.logger.error("Failed to start speech recognition", e) toast(str(R.string.stt_start_failed)) destroyRecognizer() setState(RecordingState.IDLE) @@ -264,11 +289,13 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * code, and inserts the result at the cursor. */ private fun handleTranscript(transcript: String) { - Log.i(TAG, "Recognized: $transcript") + context.logger.debug("Transcript received (${transcript.length} chars)") scope.launch { + // Resolved once per transcript: AI Core may have finished activating after we did. + val service = resolveLlmService() // Generate code when AI Core is present; fall back to the raw transcript so speech is never dropped. - val generated = if (llmService != null) generateCodeFromVoice(transcript) else null - val generationFailed = llmService != null && generated == null + val generated = service?.let { generateCodeFromVoice(it, transcript) } + val generationFailed = service != null && generated == null val output = generated ?: transcript withContext(Dispatchers.Main) { try { @@ -291,40 +318,88 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { /** * Generates code from a voice command using the LLM. * + * @param service AI Core's router, resolved by the caller so one transcript resolves it once * @param voiceText The transcribed text from speech-to-text - * @param language Programming language context (e.g., "kotlin", "java") + * @param language Programming language context; defaults to the open file's language * @return Generated code snippet, or null if generation failed (details are logged). */ - suspend fun generateCodeFromVoice(voiceText: String, language: String = "kotlin"): String? { + suspend fun generateCodeFromVoice( + service: LlmInferenceService, + voiceText: String, + language: String = currentLanguageId(), + ): String? { return try { - val service = llmService ?: run { - Log.w(TAG, "LlmInferenceService not available - cannot generate code") - return null - } - // Build a completion prompt for code generation val prompt = """ User request: $voiceText - Generate $language code to fulfill this request. Return only the code, no explanation. + Generate $language code to fulfill this request. Return only the code, + with no explanation and no markdown code fences. Code: """.trimIndent() // AI Core routes to the user-selected backend; we don't pick one here. val config = LlmInferenceService.LlmConfig(AUTO_BACKEND_ID) - val response = service.generateCompletion(prompt, config).get() + // The router hands back a blocking future, so the wait is pinned to Dispatchers.IO + // rather than to whichever dispatcher the caller happens to be on. + val response = withContext(Dispatchers.IO) { + val future = service.generateCompletion(prompt, config) + try { + future.get(GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + // Cancel only this future; cancelGeneration() is router-wide and would stop other plugins. + future.cancel(true) + context.logger.warn("Code generation timed out after ${GENERATION_TIMEOUT_SECONDS}s") + null + } + } ?: return null if (response.success) { - response.text?.trim() + response.text?.let { stripCodeFences(it) }?.takeIf { it.isNotBlank() } } else { - Log.w(TAG, "Code generation failed: ${response.error}") + context.logger.warn("Code generation failed: ${response.error}") null } } catch (e: Exception) { - Log.e(TAG, "Error generating code from voice", e) + context.logger.error("Error generating code from voice", e) null } } + /** + * The language of the file being edited, so the model is asked for the language actually + * in front of the user rather than the plugin's own default. + * + * @return the host's language id for the open file, or [DEFAULT_LANGUAGE] when unknown + */ + private fun currentLanguageId(): String = try { + editorService?.getCurrentLanguageId()?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE + } catch (e: Exception) { + context.logger.warn("Could not resolve the editor language", e) + DEFAULT_LANGUAGE + } + + /** + * Unwraps the markdown code fence a chat-tuned model puts around its answer, so the editor + * receives code rather than backticks and a language tag. + * + * @param raw the backend's response text + * @return the first fenced block's body, the reply itself when it carries no fence, or + * empty when the reply was nothing but fences + */ + private fun stripCodeFences(raw: String): String { + val text = raw.trim() + + val fenced = FENCED_BLOCK.find(text)?.groupValues?.get(1) + if (fenced != null) return fenced.trim('\n', '\r').trimEnd() + + // A fence the model never closed still has to lose its opening line. + if (text.startsWith(FENCE)) { + return text.substringAfter('\n', "").removeSuffix(FENCE).trim() + } + + return text + } + /** * Inserts generated code at the cursor position. */ @@ -334,7 +409,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { return try { editorService?.insertTextAtCursor(code) ?: false } catch (e: Exception) { - Log.e(TAG, "Failed to insert text at cursor", e) + context.logger.error("Failed to insert text at cursor", e) false } } @@ -356,11 +431,11 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { */ fun requestMicrophonePermission() { if (hasMicrophonePermission()) { - Log.d(TAG, "Microphone permission already granted") + context.logger.debug("Microphone permission already granted") return } - Log.d(TAG, "Requesting microphone permission...") + context.logger.debug("Requesting microphone permission") try { // Must be the host Activity: the plugin's androidContext is a ContextThemeWrapper, // never an Activity, and RECORD_AUDIO is owned by the host app's UID. @@ -371,10 +446,10 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { PERMISSION_REQUEST_CODE ) } else { - Log.w(TAG, "No host Activity available to request microphone permission") + context.logger.warn("No host Activity available to request microphone permission") } } catch (e: Exception) { - Log.e(TAG, "Error requesting microphone permission", e) + context.logger.error("Error requesting microphone permission", e) } } @@ -385,7 +460,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { try { recognizer.destroy() } catch (e: Exception) { - Log.w(TAG, "Failed to destroy SpeechRecognizer", e) + context.logger.warn("Failed to destroy SpeechRecognizer", e) } } } @@ -434,7 +509,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { try { Toast.makeText(hostContext(), message, Toast.LENGTH_LONG).show() } catch (e: Exception) { - Log.w(TAG, "Failed to show toast", e) + context.logger.warn("Failed to show toast", e) } } @@ -454,5 +529,17 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { /** Sentinel backend id: AI Core resolves the user-selected backend for us. */ private const val AUTO_BACKEND_ID = "auto" + + /** Bounds one generation so a wedged backend can't strand the toolbar on the spinner. */ + private const val GENERATION_TIMEOUT_SECONDS = 60L + + /** Used only when the host cannot name the open file's language. */ + private const val DEFAULT_LANGUAGE = "kotlin" + + private const val FENCE = "```" + + /** First markdown fenced block; group 1 is the body, with the language info dropped. */ + private val FENCED_BLOCK = + Regex("```[ \\t]*[A-Za-z0-9+#.\\-]*[ \\t]*\\r?\\n([\\s\\S]*?)```") } } From 23ddf2ef45a29c00707fc84ecf4bc9ca8fbff4d7 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 9 Sep 2026 08:51:19 -0500 Subject: [PATCH 2/4] fix(speech-to-text): address ADFA-5403 review findings Resolve the LLM service through getPluginService as well, and drop the cached reference when AI Core unloads. Tune the generation request (system prompt, temperature, maxTokens) instead of scraping fences off the reply, size the timeout to that token budget, and await the future cancellably so plugin teardown unwinds it. Rewrite the fence stripper to handle a lead-in line, an unclosed fence and a one-line fenced reply. Guard logging against an uninitialized context during teardown. --- .../plugins/stt/SpeechToTextPlugin.kt | 246 +++++++++++++----- 1 file changed, 178 insertions(+), 68 deletions(-) diff --git a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt index 5c6f452a..115d6227 100644 --- a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt +++ b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt @@ -15,6 +15,8 @@ import android.widget.Toast import androidx.core.content.ContextCompat import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.PluginLogger import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry @@ -25,13 +27,17 @@ import com.itsaky.androidide.plugins.services.IdeEditorService import com.itsaky.androidide.plugins.services.IdeUIService import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Speech-to-Text Plugin provides voice-to-code capabilities. @@ -49,6 +55,8 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { private val scope = CoroutineScope(Dispatchers.IO) private lateinit var context: PluginContext + /** Populated on the main thread by activate(), read from [scope]'s IO threads. */ + @Volatile private var llmService: LlmInferenceService? = null private var editorService: IdeEditorService? = null private var uiService: IdeUIService? = null @@ -77,9 +85,17 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { RecordingState.PROCESSING -> R.drawable.ic_processing } + /** + * The host logger, or null before [initialize] ran. [context] is `lateinit`, and the host + * still calls [deactivate]/[dispose] after a failed or skipped load. + */ + private val logger: PluginLogger? + get() = if (::context.isInitialized) context.logger else null + override fun initialize(context: PluginContext): Boolean { this.context = context - context.logger.info("Plugin initialized") + context.addPluginLifecycleListener(aiCoreLifecycleListener) + logger?.info("Plugin initialized") return true } @@ -92,20 +108,20 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { resolveLlmService() if (editorService == null) { - context.logger.warn("IdeEditorService not available - editor integration disabled") + logger?.warn("IdeEditorService not available - editor integration disabled") } if (uiService == null) { - context.logger.warn("IdeUIService not available - toolbar icon will not animate between states") + logger?.warn("IdeUIService not available - toolbar icon will not animate between states") } - context.logger.info("Plugin activated") + logger?.info("Plugin activated") return true } /** * Resolves AI Core's router, caching only a successful lookup so a later call retries. - * AI Core publishes it to the process-global [SharedServices]; the plugin-local registry - * is a fallback for hosts that bridge it there instead. + * Hosts differ in where they publish it: the process-global [SharedServices], AI Core's + * per-plugin export, or the plugin-local registry. * * @return the service, or null while AI Core is absent or has not activated yet */ @@ -114,30 +130,51 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { val service = try { SharedServices.get(LlmInferenceService::class.java) + ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java) ?: context.services.get(LlmInferenceService::class.java) } catch (e: Exception) { - context.logger.warn("Error resolving LlmInferenceService", e) + logger?.warn("Error resolving LlmInferenceService", e) null } if (service == null) { - context.logger.info("LlmInferenceService not available yet - install/activate the AI Core plugin") + logger?.info("LlmInferenceService not available yet - install/activate the AI Core plugin") return null } llmService = service - context.logger.info("LlmInferenceService resolved - voice generation enabled") + logger?.info("LlmInferenceService resolved - voice generation enabled") return service } + /** + * Drops the cached router when AI Core goes away, so the next transcript re-resolves rather + * than calling into an unloaded plugin's object and pinning its ClassLoader for the process. + */ + private val aiCoreLifecycleListener = object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) = Unit + override fun onPluginDeactivated(pluginId: String) = forgetLlmService(pluginId) + override fun onPluginUninstalled(pluginId: String) = forgetLlmService(pluginId) + } + + private fun forgetLlmService(pluginId: String) { + if (pluginId != AI_CORE_PLUGIN_ID) return + llmService = null + logger?.info("AI Core went away - dropped the cached LlmInferenceService") + } + override fun deactivate(): Boolean { - context.logger.info("Plugin deactivating") + logger?.info("Plugin deactivating") destroyRecognizer() return true } override fun dispose() { - context.logger.info("Plugin disposed") + logger?.info("Plugin disposed") + if (::context.isInitialized) { + context.removePluginLifecycleListener(aiCoreLifecycleListener) + } + llmService = null destroyRecognizer() // Tear down the transcript-processing scope so no LLM/generation coroutine // outlives the plugin after unload. @@ -246,7 +283,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { recognizer.startListening(intent) setState(RecordingState.RECORDING) } catch (e: Exception) { - context.logger.error("Failed to start speech recognition", e) + logger?.error("Failed to start speech recognition", e) toast(str(R.string.stt_start_failed)) destroyRecognizer() setState(RecordingState.IDLE) @@ -289,12 +326,14 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * code, and inserts the result at the cursor. */ private fun handleTranscript(transcript: String) { - context.logger.debug("Transcript received (${transcript.length} chars)") + logger?.debug("Transcript received (${transcript.length} chars)") + // Read here: onResults is a main-thread callback, and the IO dispatcher below is not. + val language = currentLanguageId() scope.launch { // Resolved once per transcript: AI Core may have finished activating after we did. val service = resolveLlmService() // Generate code when AI Core is present; fall back to the raw transcript so speech is never dropped. - val generated = service?.let { generateCodeFromVoice(it, transcript) } + val generated = service?.let { generateCodeFromVoice(it, transcript, language) } val generationFailed = service != null && generated == null val output = generated ?: transcript withContext(Dispatchers.Main) { @@ -320,86 +359,114 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * * @param service AI Core's router, resolved by the caller so one transcript resolves it once * @param voiceText The transcribed text from speech-to-text - * @param language Programming language context; defaults to the open file's language + * @param language Programming language of the open file, read by the caller on the main thread * @return Generated code snippet, or null if generation failed (details are logged). */ suspend fun generateCodeFromVoice( service: LlmInferenceService, voiceText: String, - language: String = currentLanguageId(), + language: String, ): String? { return try { - // Build a completion prompt for code generation - val prompt = """ - User request: $voiceText - - Generate $language code to fulfill this request. Return only the code, - with no explanation and no markdown code fences. - Code: - """.trimIndent() + val prompt = "Write $language code for this request: $voiceText" // AI Core routes to the user-selected backend; we don't pick one here. - val config = LlmInferenceService.LlmConfig(AUTO_BACKEND_ID) - // The router hands back a blocking future, so the wait is pinned to Dispatchers.IO - // rather than to whichever dispatcher the caller happens to be on. + val config = LlmInferenceService.LlmConfig(AUTO_BACKEND_ID).apply { + systemPrompt = GENERATION_SYSTEM_PROMPT + temperature = GENERATION_TEMPERATURE + maxTokens = MAX_GENERATION_TOKENS + // No FENCE stop sequence, unlike the inline-completion sibling: a block answer + // opens with a fence, so stopping there would truncate it to nothing. + } + // await() is cancellation-aware, so dispose() unwinds this instead of leaving an IO + // thread parked in Future.get for the rest of the timeout. val response = withContext(Dispatchers.IO) { - val future = service.generateCompletion(prompt, config) - try { - future.get(GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) - } catch (e: TimeoutException) { - // Cancel only this future; cancelGeneration() is router-wide and would stop other plugins. - future.cancel(true) - context.logger.warn("Code generation timed out after ${GENERATION_TIMEOUT_SECONDS}s") - null + withTimeoutOrNull(GENERATION_TIMEOUT_SECONDS * MILLIS_PER_SECOND) { + service.generateCompletion(prompt, config).await() } - } ?: return null + } + if (response == null) { + // Only maxTokens bounds the backend: cancelling the future does not stop decoding, + // and cancelGeneration() is router-wide and would abort other plugins' requests. + logger?.warn("Code generation timed out after ${GENERATION_TIMEOUT_SECONDS}s") + return null + } if (response.success) { response.text?.let { stripCodeFences(it) }?.takeIf { it.isNotBlank() } } else { - context.logger.warn("Code generation failed: ${response.error}") + logger?.warn("Code generation failed: ${response.error}") null } + } catch (e: CancellationException) { + // A plugin unload cancels the scope; that is teardown, not a generation error. + throw e } catch (e: Exception) { - context.logger.error("Error generating code from voice", e) + logger?.error("Error generating code from voice", e) null } } /** * The language of the file being edited, so the model is asked for the language actually - * in front of the user rather than the plugin's own default. + * in front of the user rather than the plugin's own default. Call on the main thread. * * @return the host's language id for the open file, or [DEFAULT_LANGUAGE] when unknown */ private fun currentLanguageId(): String = try { editorService?.getCurrentLanguageId()?.takeIf { it.isNotBlank() } ?: DEFAULT_LANGUAGE } catch (e: Exception) { - context.logger.warn("Could not resolve the editor language", e) + logger?.warn("Could not resolve the editor language", e) DEFAULT_LANGUAGE } /** * Unwraps the markdown code fence a chat-tuned model puts around its answer, so the editor - * receives code rather than backticks and a language tag. + * receives code rather than a lead-in line, backticks and a language tag. * * @param raw the backend's response text - * @return the first fenced block's body, the reply itself when it carries no fence, or - * empty when the reply was nothing but fences + * @return the fenced block's body, the reply itself when it carries no fence, or empty when + * the reply was nothing but fences */ private fun stripCodeFences(raw: String): String { - val text = raw.trim() - - val fenced = FENCED_BLOCK.find(text)?.groupValues?.get(1) - if (fenced != null) return fenced.trim('\n', '\r').trimEnd() - - // A fence the model never closed still has to lose its opening line. - if (text.startsWith(FENCE)) { - return text.substringAfter('\n', "").removeSuffix(FENCE).trim() + val lines = raw.trim().lines() + // The fence can open on any line: a model often writes "Here is the code:" first. + val opening = lines.indexOfFirst { it.trimStart().startsWith(FENCE) } + if (opening < 0) return raw.trim() + + val afterFence = lines[opening].trim().removePrefix(FENCE) + // A one-line reply closes on its opening line; otherwise that line is only the info string. + val firstCodeLine = if (afterFence.endsWith(FENCE)) { + stripLanguageInfo(afterFence.removeSuffix(FENCE).trim()) + } else { + "" } - return text + val rest = lines.drop(opening + 1) + val closing = rest.indexOfFirst { it.trimStart().startsWith(FENCE) } + // An unclosed fence (a reply truncated at maxTokens) keeps everything that did arrive. + val body = if (closing >= 0) rest.take(closing) else rest + + return (if (firstCodeLine.isEmpty()) body else listOf(firstCodeLine) + body) + .joinToString("\n") + .trim() + .removeSuffix(FENCE) + .trimEnd() } + /** + * Drops the language info from a one-line fenced reply, so `kotlin println()` (the remains + * of ` ```kotlin println()``` `) yields just the code. + * + * @param fenceLine the opening fence line with its fence markers already removed + * @return the line without a leading language tag, empty when that was all it held + */ + private fun stripLanguageInfo(fenceLine: String): String = + if (fenceLine.substringBefore(' ').lowercase() in LANGUAGE_TAGS) { + fenceLine.substringAfter(' ', "").trim() + } else { + fenceLine + } + /** * Inserts generated code at the cursor position. */ @@ -409,7 +476,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { return try { editorService?.insertTextAtCursor(code) ?: false } catch (e: Exception) { - context.logger.error("Failed to insert text at cursor", e) + logger?.error("Failed to insert text at cursor", e) false } } @@ -431,11 +498,11 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { */ fun requestMicrophonePermission() { if (hasMicrophonePermission()) { - context.logger.debug("Microphone permission already granted") + logger?.debug("Microphone permission already granted") return } - context.logger.debug("Requesting microphone permission") + logger?.debug("Requesting microphone permission") try { // Must be the host Activity: the plugin's androidContext is a ContextThemeWrapper, // never an Activity, and RECORD_AUDIO is owned by the host app's UID. @@ -446,10 +513,10 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { PERMISSION_REQUEST_CODE ) } else { - context.logger.warn("No host Activity available to request microphone permission") + logger?.warn("No host Activity available to request microphone permission") } } catch (e: Exception) { - context.logger.error("Error requesting microphone permission", e) + logger?.error("Error requesting microphone permission", e) } } @@ -460,7 +527,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { try { recognizer.destroy() } catch (e: Exception) { - context.logger.warn("Failed to destroy SpeechRecognizer", e) + logger?.warn("Failed to destroy SpeechRecognizer", e) } } } @@ -509,7 +576,7 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { try { Toast.makeText(hostContext(), message, Toast.LENGTH_LONG).show() } catch (e: Exception) { - context.logger.warn("Failed to show toast", e) + logger?.warn("Failed to show toast", e) } } @@ -530,16 +597,59 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { /** Sentinel backend id: AI Core resolves the user-selected backend for us. */ private const val AUTO_BACKEND_ID = "auto" - /** Bounds one generation so a wedged backend can't strand the toolbar on the spinner. */ - private const val GENERATION_TIMEOUT_SECONDS = 60L + /** AI Core's plugin id: its per-plugin service export, and the cache-invalidation key. */ + private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore" + + private const val FENCE = "```" + + private const val MILLIS_PER_SECOND = 1000L + + /** Low, because a voice command wants the obvious code rather than a creative answer. */ + private const val GENERATION_TEMPERATURE = 0.2f + + /** A voice command asks for a snippet, not a file; this also bounds the timeout below. */ + private const val MAX_GENERATION_TOKENS = 512 + + /** Floor throughput of a phone-class llama.cpp backend, used only to size the timeout. */ + private const val SLOWEST_TOKENS_PER_SECOND = 5 + + /** Headroom for loading the model and evaluating the prompt before the first token. */ + private const val PROMPT_EVAL_SECONDS = 30 + + /** + * Bounds one generation so a wedged backend can't strand the toolbar on the spinner. + * Sized to [MAX_GENERATION_TOKENS] so a slow device finishes rather than being cut off. + */ + private const val GENERATION_TIMEOUT_SECONDS = + MAX_GENERATION_TOKENS / SLOWEST_TOKENS_PER_SECOND + PROMPT_EVAL_SECONDS + + /** Keeps fences and prose out of the reply, so stripCodeFences is only a safety net. */ + private const val GENERATION_SYSTEM_PROMPT = + "You are a code generator inside a code editor. Reply with the raw code that " + + "fulfils the request and nothing else. Do not explain. Do not use markdown or " + + "code fences ($FENCE). Do not name the language." /** Used only when the host cannot name the open file's language. */ private const val DEFAULT_LANGUAGE = "kotlin" - private const val FENCE = "```" - - /** First markdown fenced block; group 1 is the body, with the language info dropped. */ - private val FENCED_BLOCK = - Regex("```[ \\t]*[A-Za-z0-9+#.\\-]*[ \\t]*\\r?\\n([\\s\\S]*?)```") + /** Bare fence infos ("java", "kotlin", ...) that are never code. */ + private val LANGUAGE_TAGS = setOf( + "java", "kotlin", "kt", "python", "py", "xml", "json", "gradle", "groovy", + "javascript", "js", "typescript", "ts", "c", "cpp", "c++", "sh", "bash", + "text", "plaintext", "code", + ) } } + +/** + * Suspends until this future completes, cancelling it if the coroutine is cancelled. + * @receiver the future to await + * @return the future's completed value + */ +private suspend fun CompletableFuture.await(): T = + suspendCancellableCoroutine { cont -> + whenComplete { value, error -> + if (error == null) cont.resume(value) else cont.resumeWithException(error) + } + cont.invokeOnCancellation { cancel(true) } + } From bd5ae119390f35f8af9d947d3f7524dceb8b4055 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 10 Sep 2026 08:31:58 -0500 Subject: [PATCH 3/4] fix(speech-to-text): address ADFA-5403 second-pass review findings Restore IDLE state and the raw-transcript fallback when AI Core cancels the shared future, release listener/scope/service in deactivate(), and stop the fence stripper from inserting prose (F17) or eating real code (F18, F20). --- .../plugins/stt/SpeechToTextPlugin.kt | 135 +++++++++++++----- 1 file changed, 98 insertions(+), 37 deletions(-) diff --git a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt index 115d6227..2b7f3f23 100644 --- a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt +++ b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt @@ -31,11 +31,13 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.CompletableFuture +import kotlin.coroutines.coroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -53,7 +55,9 @@ import kotlin.coroutines.resumeWithException */ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { - private val scope = CoroutineScope(Dispatchers.IO) + /** Cancelled by [teardown], so [activate] rebuilds it when the plugin is re-enabled. */ + @Volatile + private var scope = CoroutineScope(Dispatchers.IO) private lateinit var context: PluginContext /** Populated on the main thread by activate(), read from [scope]'s IO threads. */ @Volatile @@ -94,12 +98,17 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { override fun initialize(context: PluginContext): Boolean { this.context = context - context.addPluginLifecycleListener(aiCoreLifecycleListener) logger?.info("Plugin initialized") return true } override fun activate(): Boolean { + // deactivate() cancels the scope, so a re-enabled plugin needs a fresh one. + if (!scope.isActive) { + scope = CoroutineScope(Dispatchers.IO) + } + context.addPluginLifecycleListener(aiCoreLifecycleListener) + // Get services from plugin context editorService = context.services.get(IdeEditorService::class.java) uiService = context.services.get(IdeUIService::class.java) @@ -165,19 +174,26 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { override fun deactivate(): Boolean { logger?.info("Plugin deactivating") - destroyRecognizer() + teardown() return true } override fun dispose() { logger?.info("Plugin disposed") + teardown() + } + + /** + * Releases everything that could outlive a disabled plugin: an in-flight generation that + * would still write into the user's file, host callbacks to a dead instance, and the + * cached router that pins AI Core's ClassLoader. Idempotent - dispose() follows deactivate(). + */ + private fun teardown() { if (::context.isInitialized) { - context.removePluginLifecycleListener(aiCoreLifecycleListener) + runCatching { context.removePluginLifecycleListener(aiCoreLifecycleListener) } } llmService = null destroyRecognizer() - // Tear down the transcript-processing scope so no LLM/generation coroutine - // outlives the plugin after unload. scope.cancel() } @@ -330,14 +346,14 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { // Read here: onResults is a main-thread callback, and the IO dispatcher below is not. val language = currentLanguageId() scope.launch { - // Resolved once per transcript: AI Core may have finished activating after we did. - val service = resolveLlmService() - // Generate code when AI Core is present; fall back to the raw transcript so speech is never dropped. - val generated = service?.let { generateCodeFromVoice(it, transcript, language) } - val generationFailed = service != null && generated == null - val output = generated ?: transcript - withContext(Dispatchers.Main) { - try { + try { + // Resolved once per transcript: AI Core may have finished activating after we did. + val service = resolveLlmService() + // Generate code when AI Core is present; fall back to the raw transcript so speech is never dropped. + val generated = service?.let { generateCodeFromVoice(it, transcript, language) } + val generationFailed = service != null && generated == null + val output = generated ?: transcript + withContext(Dispatchers.Main) { val inserted = insertCodeAtCursor(output) toast( when { @@ -346,10 +362,11 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { else -> str(R.string.stt_inserted) } ) - } finally { - // Back to idle (mic) regardless of how processing ended. - setState(RecordingState.IDLE) } + } finally { + // Posted, not dispatched: a cancelled coroutine can no longer suspend, and the + // toolbar must leave the spinner even then. + runOnMain { setState(RecordingState.IDLE) } } } } @@ -392,14 +409,18 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { return null } if (response.success) { - response.text?.let { stripCodeFences(it) }?.takeIf { it.isNotBlank() } + response.text?.let { stripCodeFences(it, language) }?.takeIf { it.isNotBlank() } } else { logger?.warn("Code generation failed: ${response.error}") null } } catch (e: CancellationException) { // A plugin unload cancels the scope; that is teardown, not a generation error. - throw e + if (!coroutineContext.isActive) throw e + // AI Core's cancelGeneration() is router-wide, so another plugin's Stop button can + // cancel our future while we are alive; that is a failed generation, not a teardown. + logger?.warn("Code generation was cancelled by the backend", e) + null } catch (e: Exception) { logger?.error("Error generating code from voice", e) null @@ -424,22 +445,19 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * receives code rather than a lead-in line, backticks and a language tag. * * @param raw the backend's response text - * @return the fenced block's body, the reply itself when it carries no fence, or empty when - * the reply was nothing but fences + * @param language the language asked for, the only fence info string treated as removable + * @return the fenced block's body, the code of an unfenced reply, or empty when the reply + * held no code at all */ - private fun stripCodeFences(raw: String): String { + private fun stripCodeFences(raw: String, language: String): String { val lines = raw.trim().lines() // The fence can open on any line: a model often writes "Here is the code:" first. val opening = lines.indexOfFirst { it.trimStart().startsWith(FENCE) } - if (opening < 0) return raw.trim() + if (opening < 0) return dropLeadingProse(lines) + // The opening line can carry code after its info string whether or not it also closes. val afterFence = lines[opening].trim().removePrefix(FENCE) - // A one-line reply closes on its opening line; otherwise that line is only the info string. - val firstCodeLine = if (afterFence.endsWith(FENCE)) { - stripLanguageInfo(afterFence.removeSuffix(FENCE).trim()) - } else { - "" - } + val firstCodeLine = stripLanguageInfo(afterFence.removeSuffix(FENCE).trim(), language) val rest = lines.drop(opening + 1) val closing = rest.indexOfFirst { it.trimStart().startsWith(FENCE) } @@ -454,18 +472,52 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { } /** - * Drops the language info from a one-line fenced reply, so `kotlin println()` (the remains + * Keeps an unfenced reply from its first line of code onwards, so a lead-in or a refusal is + * never written into the open file. An all-prose reply yields empty, which the caller + * reports as a failed generation and answers with the raw transcript. + * + * @param lines the trimmed reply, split into lines + * @return the reply from its first code line on, or empty when it holds none + */ + private fun dropLeadingProse(lines: List): String { + val firstCode = lines.indexOfFirst { it.isNotBlank() && !isProse(it.trim()) } + return if (firstCode < 0) "" else lines.drop(firstCode).joinToString("\n").trimEnd() + } + + /** + * Detects a natural-language line: a lead-in ("Here is the code:") or a refusal ("I cannot + * write that code."). Deliberately narrow, because the system prompt asks for bare code and + * a false positive throws real output away. + * + * @param line a trimmed, non-empty line + * @return true when the line reads as an English sentence rather than as code + */ + private fun isProse(line: String): Boolean = + line.first().isUpperCase() && + line.last() in SENTENCE_TERMINATORS && + line.none { it in CODE_PUNCTUATION } && + line.count { it == ' ' } >= MIN_PROSE_SPACES + + /** + * Drops the language info from a fence's opening line, so `kotlin println()` (the remains * of ` ```kotlin println()``` `) yields just the code. * * @param fenceLine the opening fence line with its fence markers already removed - * @return the line without a leading language tag, empty when that was all it held + * @param language the language asked for; any other tag with code behind it is left alone + * @return the line without its language tag, empty when the tag was all it held */ - private fun stripLanguageInfo(fenceLine: String): String = - if (fenceLine.substringBefore(' ').lowercase() in LANGUAGE_TAGS) { - fenceLine.substringAfter(' ', "").trim() - } else { - fenceLine - } + private fun stripLanguageInfo(fenceLine: String, language: String): String { + val tag = fenceLine.substringBefore(' ') + if (tag.lowercase() !in LANGUAGE_TAGS) return fenceLine + + val rest = fenceLine.substringAfter(' ', "").trim() + // A tag on its own is always an info string, whatever language it names. + if (rest.isEmpty()) return "" + // With code behind it the tag may be code itself, so require the tag we asked for and a + // remainder that starts a name - `c = a + b` and `bash -c "..."` are code, not info. + val startsName = rest.first().isLetter() || rest.first() == '_' || rest.first() == '@' + return if (startsName && tag.equals(language, ignoreCase = true)) rest else fenceLine + } /** * Inserts generated code at the cursor position. @@ -632,6 +684,15 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { /** Used only when the host cannot name the open file's language. */ private const val DEFAULT_LANGUAGE = "kotlin" + /** Ends a sentence; a code line that reaches one of these also holds code punctuation. */ + private const val SENTENCE_TERMINATORS = ".:!?" + + /** Marks a line as code however sentence-like it otherwise reads. */ + private const val CODE_PUNCTUATION = "(){}[];=<>" + + /** Three words or more, matching the sibling's isPreamble; shorter lines stay. */ + private const val MIN_PROSE_SPACES = 2 + /** Bare fence infos ("java", "kotlin", ...) that are never code. */ private val LANGUAGE_TAGS = setOf( "java", "kotlin", "kt", "python", "py", "xml", "json", "gradle", "groovy", From c1e9b9bd446f5341819c82def60d19507d47a767 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Fri, 11 Sep 2026 08:20:06 -0500 Subject: [PATCH 4/4] fix(speech-to-text): harden voice-to-code lifecycle and fence parsing Reset state on activate, reject re-entrant captures, drop trailing prose, accept any fence tag, split null responses from timeouts, clear services. --- .../plugins/stt/SpeechToTextPlugin.kt | 71 +++++++++++++------ .../src/main/res/values/strings.xml | 1 + 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt index 2b7f3f23..33a03b4e 100644 --- a/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt +++ b/plugins/Speech-to-Text/src/main/kotlin/com/itsaky/androidide/plugins/stt/SpeechToTextPlugin.kt @@ -107,6 +107,9 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { if (!scope.isActive) { scope = CoroutineScope(Dispatchers.IO) } + // The cancelled generation's finally never reached the toolbar, so the field can still + // read PROCESSING here and would strand a re-enabled plugin on the spinner. + recordingState = RecordingState.IDLE context.addPluginLifecycleListener(aiCoreLifecycleListener) // Get services from plugin context @@ -193,6 +196,10 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycleListener) } } llmService = null + // A cancelled generation's finally still posts setState, which lands after deactivate() + // returned; without these a disabled plugin reaches into the host toolbar. + editorService = null + uiService = null destroyRecognizer() scope.cancel() } @@ -262,6 +269,13 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { private fun startVoiceCapture() { val ctx = hostContext() + // A second tap during recognition or generation would insert twice and leave the icon + // describing whichever run finished last. + if (recordingState != RecordingState.IDLE) { + toast(str(R.string.stt_busy)) + return + } + // Belt-and-suspenders: the toolbar already disables the button when no file is open, // but guard here too so a stale enabled state can't start a pointless recording. if (!hasOpenFile()) { @@ -365,8 +379,12 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { } } finally { // Posted, not dispatched: a cancelled coroutine can no longer suspend, and the - // toolbar must leave the spinner even then. - runOnMain { setState(RecordingState.IDLE) } + // toolbar must leave the spinner even then. Only our own PROCESSING is cleared. + runOnMain { + if (recordingState == RecordingState.PROCESSING) { + setState(RecordingState.IDLE) + } + } } } } @@ -395,21 +413,30 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { // No FENCE stop sequence, unlike the inline-completion sibling: a block answer // opens with a fence, so stopping there would truncate it to nothing. } + // withTimeoutOrNull reports a timeout as null, and generateCompletion is platform-typed, + // so record a future that completed with null rather than reading it as a slow model. + var completedWithoutResponse = false // await() is cancellation-aware, so dispose() unwinds this instead of leaving an IO // thread parked in Future.get for the rest of the timeout. val response = withContext(Dispatchers.IO) { withTimeoutOrNull(GENERATION_TIMEOUT_SECONDS * MILLIS_PER_SECOND) { service.generateCompletion(prompt, config).await() + .also { if (it == null) completedWithoutResponse = true } } } if (response == null) { - // Only maxTokens bounds the backend: cancelling the future does not stop decoding, - // and cancelGeneration() is router-wide and would abort other plugins' requests. - logger?.warn("Code generation timed out after ${GENERATION_TIMEOUT_SECONDS}s") + if (completedWithoutResponse) { + logger?.warn("Backend completed the request without a response") + } else { + // Only maxTokens bounds the backend: cancelling the future does not stop + // decoding, and cancelGeneration() is router-wide and would abort other + // plugins' requests. + logger?.warn("Code generation timed out after ${GENERATION_TIMEOUT_SECONDS}s") + } return null } if (response.success) { - response.text?.let { stripCodeFences(it, language) }?.takeIf { it.isNotBlank() } + response.text?.let { stripCodeFences(it) }?.takeIf { it.isNotBlank() } } else { logger?.warn("Code generation failed: ${response.error}") null @@ -445,19 +472,18 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * receives code rather than a lead-in line, backticks and a language tag. * * @param raw the backend's response text - * @param language the language asked for, the only fence info string treated as removable * @return the fenced block's body, the code of an unfenced reply, or empty when the reply * held no code at all */ - private fun stripCodeFences(raw: String, language: String): String { + private fun stripCodeFences(raw: String): String { val lines = raw.trim().lines() // The fence can open on any line: a model often writes "Here is the code:" first. val opening = lines.indexOfFirst { it.trimStart().startsWith(FENCE) } - if (opening < 0) return dropLeadingProse(lines) + if (opening < 0) return dropProse(lines) // The opening line can carry code after its info string whether or not it also closes. val afterFence = lines[opening].trim().removePrefix(FENCE) - val firstCodeLine = stripLanguageInfo(afterFence.removeSuffix(FENCE).trim(), language) + val firstCodeLine = stripLanguageInfo(afterFence.removeSuffix(FENCE).trim()) val rest = lines.drop(opening + 1) val closing = rest.indexOfFirst { it.trimStart().startsWith(FENCE) } @@ -472,17 +498,17 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { } /** - * Keeps an unfenced reply from its first line of code onwards, so a lead-in or a refusal is - * never written into the open file. An all-prose reply yields empty, which the caller - * reports as a failed generation and answers with the raw transcript. + * Drops the natural-language lines of an unfenced reply wherever they sit, so neither a + * lead-in nor a trailing explanation is written into the open file. An all-prose reply yields + * empty, which the caller reports as a failed generation and answers with the raw transcript. * * @param lines the trimmed reply, split into lines - * @return the reply from its first code line on, or empty when it holds none + * @return the reply's code lines, or empty when it holds none */ - private fun dropLeadingProse(lines: List): String { - val firstCode = lines.indexOfFirst { it.isNotBlank() && !isProse(it.trim()) } - return if (firstCode < 0) "" else lines.drop(firstCode).joinToString("\n").trimEnd() - } + private fun dropProse(lines: List): String = + lines.filterNot { it.isNotBlank() && isProse(it.trim()) } + .joinToString("\n") + .trim() /** * Detects a natural-language line: a lead-in ("Here is the code:") or a refusal ("I cannot @@ -503,20 +529,19 @@ class SpeechToTextPlugin : IPlugin, UIExtension, DocumentationExtension { * of ` ```kotlin println()``` `) yields just the code. * * @param fenceLine the opening fence line with its fence markers already removed - * @param language the language asked for; any other tag with code behind it is left alone * @return the line without its language tag, empty when the tag was all it held */ - private fun stripLanguageInfo(fenceLine: String, language: String): String { + private fun stripLanguageInfo(fenceLine: String): String { val tag = fenceLine.substringBefore(' ') if (tag.lowercase() !in LANGUAGE_TAGS) return fenceLine val rest = fenceLine.substringAfter(' ', "").trim() // A tag on its own is always an info string, whatever language it names. if (rest.isEmpty()) return "" - // With code behind it the tag may be code itself, so require the tag we asked for and a - // remainder that starts a name - `c = a + b` and `bash -c "..."` are code, not info. + // With code behind it the tag may be code itself, so require a remainder that starts a + // name - `c = a + b` and `bash -c "..."` are code, not an info string plus code. val startsName = rest.first().isLetter() || rest.first() == '_' || rest.first() == '@' - return if (startsName && tag.equals(language, ignoreCase = true)) rest else fenceLine + return if (startsName) rest else fenceLine } /** diff --git a/plugins/Speech-to-Text/src/main/res/values/strings.xml b/plugins/Speech-to-Text/src/main/res/values/strings.xml index 61175e5f..95e05eb4 100644 --- a/plugins/Speech-to-Text/src/main/res/values/strings.xml +++ b/plugins/Speech-to-Text/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Listening… speak now. Couldn\'t start recording. Please close any app using the microphone and try again. + Still working on your last voice command. Wait for it to finish, then tap the microphone again. No words were recognized. Speak clearly and a little louder, then tap the microphone to try again.