From 2a09837e58d08ed2a3d1895eff57d4ba6bd2bfa6 Mon Sep 17 00:00:00 2001 From: "Donald H." Date: Tue, 10 Feb 2026 21:07:21 -0500 Subject: [PATCH 1/2] Add dictation command processor for voice input Adds a post-processing step to the voice input pipeline that converts spoken command phrases (e.g. "new line", "dollar sign", "caps on") into their corresponding characters and formatting. Covers 69 commands across 10 categories, each independently toggleable in a new Dictation Commands settings sub-page under Voice Input. Includes 108 JVM unit tests. --- build.gradle | 4 + java/res/values/strings-uix.xml | 20 + .../latin/uix/VoiceInputSettingKeys.kt | 46 ++ .../latin/uix/actions/VoiceInputAction.kt | 33 +- .../latin/uix/settings/SettingsNavigator.kt | 2 + .../latin/uix/settings/pages/VoiceInput.kt | 76 +++ .../uix/utils/DictationCommandProcessor.kt | 420 ++++++++++++ .../utils/DictationCommandProcessorTest.kt | 626 ++++++++++++++++++ 8 files changed, 1224 insertions(+), 3 deletions(-) create mode 100644 java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt create mode 100644 java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt diff --git a/build.gradle b/build.gradle index 79c62096fc..7b3c86eeed 100644 --- a/build.gradle +++ b/build.gradle @@ -328,6 +328,10 @@ android { res.srcDirs = ['java/unstable/res', translationsWithoutEngValues('translations/devbuild')] } + test { + java.srcDirs = ['java/test'] + } + androidTest { res.srcDirs = ['tests/res'] java.srcDirs = ['tests/src'] diff --git a/java/res/values/strings-uix.xml b/java/res/values/strings-uix.xml index 6627fea9f4..5a33ae51c0 100644 --- a/java/res/values/strings-uix.xml +++ b/java/res/values/strings-uix.xml @@ -538,6 +538,26 @@ Models To change the models, visit Languages & Models menu + + Dictation Commands + Replace spoken phrases like \"new line\" or \"dollar sign\" with symbols and formatting + Formatting + New line, new paragraph, tab, numeral, no space on/off + Capitalization + Caps on/off, all caps + Punctuation & Brackets + Quotes, brackets, parentheses, dash, ellipsis + Symbols + Ampersand, asterisk, at sign, hashtag, etc. + Math Symbols + Equal, plus, minus, greater than, less than + Currency Symbols + Dollar, euro, pound, yen, cent + Emoticons + Smiley, frowny, winky face + Intellectual Property Marks + Copyright, registered, trademark + Text Prediction Transformer LM diff --git a/java/src/org/futo/inputmethod/latin/uix/VoiceInputSettingKeys.kt b/java/src/org/futo/inputmethod/latin/uix/VoiceInputSettingKeys.kt index 8eb4d8e18e..8d2cbe06d0 100644 --- a/java/src/org/futo/inputmethod/latin/uix/VoiceInputSettingKeys.kt +++ b/java/src/org/futo/inputmethod/latin/uix/VoiceInputSettingKeys.kt @@ -67,4 +67,50 @@ val LANGUAGE_TOGGLES = SettingsKey( val USE_PERSONAL_DICT = SettingsKey( key = booleanPreferencesKey("use_personal_dict_voice_input"), default = true +) + +// Dictation command settings +val DICTATION_COMMANDS_ENABLED = SettingsKey( + key = booleanPreferencesKey("dictation_commands_enabled"), + default = true +) + +val DICTATION_FORMATTING = SettingsKey( + key = booleanPreferencesKey("dictation_formatting"), + default = true +) + +val DICTATION_CAPITALIZATION = SettingsKey( + key = booleanPreferencesKey("dictation_capitalization"), + default = true +) + +val DICTATION_PUNCTUATION = SettingsKey( + key = booleanPreferencesKey("dictation_punctuation"), + default = true +) + +val DICTATION_SYMBOLS = SettingsKey( + key = booleanPreferencesKey("dictation_symbols"), + default = true +) + +val DICTATION_MATH = SettingsKey( + key = booleanPreferencesKey("dictation_math"), + default = true +) + +val DICTATION_CURRENCY = SettingsKey( + key = booleanPreferencesKey("dictation_currency"), + default = true +) + +val DICTATION_EMOTICONS = SettingsKey( + key = booleanPreferencesKey("dictation_emoticons"), + default = true +) + +val DICTATION_IP_MARKS = SettingsKey( + key = booleanPreferencesKey("dictation_ip_marks"), + default = true ) \ No newline at end of file diff --git a/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt b/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt index 4bfbbca400..8e1b03240a 100644 --- a/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt +++ b/java/src/org/futo/inputmethod/latin/uix/actions/VoiceInputAction.kt @@ -25,11 +25,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import org.futo.inputmethod.latin.R -import org.futo.inputmethod.latin.uix.AUDIO_FOCUS import org.futo.inputmethod.latin.uix.Action import org.futo.inputmethod.latin.uix.ActionWindow +import org.futo.inputmethod.latin.uix.AUDIO_FOCUS import org.futo.inputmethod.latin.uix.CAN_EXPAND_SPACE import org.futo.inputmethod.latin.uix.CloseResult +import org.futo.inputmethod.latin.uix.DICTATION_CAPITALIZATION +import org.futo.inputmethod.latin.uix.DICTATION_COMMANDS_ENABLED +import org.futo.inputmethod.latin.uix.DICTATION_CURRENCY +import org.futo.inputmethod.latin.uix.DICTATION_EMOTICONS +import org.futo.inputmethod.latin.uix.DICTATION_FORMATTING +import org.futo.inputmethod.latin.uix.DICTATION_IP_MARKS +import org.futo.inputmethod.latin.uix.DICTATION_MATH +import org.futo.inputmethod.latin.uix.DICTATION_PUNCTUATION +import org.futo.inputmethod.latin.uix.DICTATION_SYMBOLS import org.futo.inputmethod.latin.uix.DISALLOW_SYMBOLS import org.futo.inputmethod.latin.uix.ENABLE_SOUND import org.futo.inputmethod.latin.uix.KeyboardManagerForAction @@ -42,6 +51,8 @@ import org.futo.inputmethod.latin.uix.VERBOSE_PROGRESS import org.futo.inputmethod.latin.uix.getSetting import org.futo.inputmethod.latin.uix.setSetting import org.futo.inputmethod.latin.uix.settings.SettingsActivity +import org.futo.inputmethod.latin.uix.utils.DictationCommandProcessor +import org.futo.inputmethod.latin.uix.utils.DictationSettings import org.futo.inputmethod.latin.uix.utils.ModelOutputSanitizer import org.futo.inputmethod.latin.xlm.UserDictionaryObserver import org.futo.inputmethod.updates.openURI @@ -118,6 +129,7 @@ private class VoiceInputActionWindow( val context = manager.getContext() private var shouldPlaySounds: Boolean = false + private var dictationSettings: DictationSettings = DictationSettings() private fun loadSettings(): RecognizerViewSettings { val enableSound = context.getSetting(ENABLE_SOUND) val verboseFeedback = false//context.getSetting(VERBOSE_PROGRESS) @@ -139,6 +151,18 @@ private class VoiceInputActionWindow( shouldPlaySounds = enableSound + dictationSettings = DictationSettings( + enabled = context.getSetting(DICTATION_COMMANDS_ENABLED), + formatting = context.getSetting(DICTATION_FORMATTING), + capitalization = context.getSetting(DICTATION_CAPITALIZATION), + punctuation = context.getSetting(DICTATION_PUNCTUATION), + symbols = context.getSetting(DICTATION_SYMBOLS), + math = context.getSetting(DICTATION_MATH), + currency = context.getSetting(DICTATION_CURRENCY), + emoticons = context.getSetting(DICTATION_EMOTICONS), + ipMarks = context.getSetting(DICTATION_IP_MARKS) + ) + return RecognizerViewSettings( shouldShowInlinePartialResult = false, shouldShowVerboseFeedback = verboseFeedback, @@ -263,8 +287,10 @@ private class VoiceInputActionWindow( wasFinished = true manager.getLifecycleScope().launch(Dispatchers.Main) { + // Sanitize first so dictation formatting chars aren't mangled by trim() val sanitized = ModelOutputSanitizer.sanitize(result, inputTransaction.textContext) - inputTransaction.commit(sanitized) + val processed = DictationCommandProcessor.process(sanitized, dictationSettings) + inputTransaction.commit(processed) manager.announce(result) manager.closeActionWindow() } @@ -273,7 +299,8 @@ private class VoiceInputActionWindow( override fun partialResult(result: String) { manager.getLifecycleScope().launch(Dispatchers.Main) { val sanitized = ModelOutputSanitizer.sanitize(result, inputTransaction.textContext) - inputTransaction.updatePartial(sanitized) + val processed = DictationCommandProcessor.process(sanitized, dictationSettings) + inputTransaction.updatePartial(processed) } } diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/SettingsNavigator.kt b/java/src/org/futo/inputmethod/latin/uix/settings/SettingsNavigator.kt index d0394742f0..e35b4f6f52 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/SettingsNavigator.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/SettingsNavigator.kt @@ -55,6 +55,7 @@ import org.futo.inputmethod.latin.uix.settings.pages.SelectLanguageScreen import org.futo.inputmethod.latin.uix.settings.pages.SelectLayoutsScreen import org.futo.inputmethod.latin.uix.settings.pages.themes.ThemeScreen import org.futo.inputmethod.latin.uix.settings.pages.TypingSettingsMenu +import org.futo.inputmethod.latin.uix.settings.pages.DictationCommandsMenu import org.futo.inputmethod.latin.uix.settings.pages.VoiceInputMenu import org.futo.inputmethod.latin.uix.settings.pages.addModelManagerNavigation import org.futo.inputmethod.latin.uix.settings.pages.buggyeditors.BuggyTextEditVariations @@ -86,6 +87,7 @@ val SettingsMenus = listOf( PredictiveTextMenu, BlacklistScreenLite, VoiceInputMenu, + DictationCommandsMenu, ActionsScreen, HelpMenu, MiscMenu, diff --git a/java/src/org/futo/inputmethod/latin/uix/settings/pages/VoiceInput.kt b/java/src/org/futo/inputmethod/latin/uix/settings/pages/VoiceInput.kt index 3d132d0e51..8dc61d5f99 100644 --- a/java/src/org/futo/inputmethod/latin/uix/settings/pages/VoiceInput.kt +++ b/java/src/org/futo/inputmethod/latin/uix/settings/pages/VoiceInput.kt @@ -5,6 +5,15 @@ import androidx.compose.runtime.Composable import org.futo.inputmethod.latin.R import org.futo.inputmethod.latin.uix.AUDIO_FOCUS import org.futo.inputmethod.latin.uix.CAN_EXPAND_SPACE +import org.futo.inputmethod.latin.uix.DICTATION_CAPITALIZATION +import org.futo.inputmethod.latin.uix.DICTATION_COMMANDS_ENABLED +import org.futo.inputmethod.latin.uix.DICTATION_CURRENCY +import org.futo.inputmethod.latin.uix.DICTATION_EMOTICONS +import org.futo.inputmethod.latin.uix.DICTATION_FORMATTING +import org.futo.inputmethod.latin.uix.DICTATION_IP_MARKS +import org.futo.inputmethod.latin.uix.DICTATION_MATH +import org.futo.inputmethod.latin.uix.DICTATION_PUNCTUATION +import org.futo.inputmethod.latin.uix.DICTATION_SYMBOLS import org.futo.inputmethod.latin.uix.DISALLOW_SYMBOLS import org.futo.inputmethod.latin.uix.ENABLE_SOUND import org.futo.inputmethod.latin.uix.PREFER_BLUETOOTH @@ -22,6 +31,66 @@ private val visibilityCheckNotSystemVoiceInput = @Composable { useDataStoreValue(USE_SYSTEM_VOICE_INPUT) == false } +val DictationCommandsMenu = UserSettingsMenu( + title = R.string.dictation_commands_title, + navPath = "dictationCommands", registerNavPath = true, + settings = listOf( + userSettingToggleDataStore( + title = R.string.dictation_commands_title, + subtitle = R.string.dictation_commands_subtitle, + setting = DICTATION_COMMANDS_ENABLED + ), + + userSettingToggleDataStore( + title = R.string.dictation_formatting_title, + subtitle = R.string.dictation_formatting_subtitle, + setting = DICTATION_FORMATTING + ), + + userSettingToggleDataStore( + title = R.string.dictation_capitalization_title, + subtitle = R.string.dictation_capitalization_subtitle, + setting = DICTATION_CAPITALIZATION + ), + + userSettingToggleDataStore( + title = R.string.dictation_punctuation_title, + subtitle = R.string.dictation_punctuation_subtitle, + setting = DICTATION_PUNCTUATION + ), + + userSettingToggleDataStore( + title = R.string.dictation_symbols_title, + subtitle = R.string.dictation_symbols_subtitle, + setting = DICTATION_SYMBOLS + ), + + userSettingToggleDataStore( + title = R.string.dictation_math_title, + subtitle = R.string.dictation_math_subtitle, + setting = DICTATION_MATH + ), + + userSettingToggleDataStore( + title = R.string.dictation_currency_title, + subtitle = R.string.dictation_currency_subtitle, + setting = DICTATION_CURRENCY + ), + + userSettingToggleDataStore( + title = R.string.dictation_emoticons_title, + subtitle = R.string.dictation_emoticons_subtitle, + setting = DICTATION_EMOTICONS + ), + + userSettingToggleDataStore( + title = R.string.dictation_ip_marks_title, + subtitle = R.string.dictation_ip_marks_subtitle, + setting = DICTATION_IP_MARKS + ) + ) +) + val VoiceInputMenu = UserSettingsMenu( title = R.string.voice_input_settings_title, navPath = "voiceInput", registerNavPath = true, @@ -82,6 +151,13 @@ val VoiceInputMenu = UserSettingsMenu( setting = USE_VAD_AUTOSTOP ).copy(visibilityCheck = visibilityCheckNotSystemVoiceInput), + userSettingNavigationItem( + title = R.string.dictation_commands_title, + subtitle = R.string.dictation_commands_subtitle, + style = NavigationItemStyle.Misc, + navigateTo = "dictationCommands" + ).copy(visibilityCheck = visibilityCheckNotSystemVoiceInput), + userSettingNavigationItem( title = R.string.voice_input_settings_change_models, subtitle = R.string.voice_input_settings_change_models_subtitle, diff --git a/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt b/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt new file mode 100644 index 0000000000..8d70d59059 --- /dev/null +++ b/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt @@ -0,0 +1,420 @@ +package org.futo.inputmethod.latin.uix.utils + +data class DictationSettings( + val enabled: Boolean = true, + val formatting: Boolean = true, + val capitalization: Boolean = true, + val punctuation: Boolean = true, + val symbols: Boolean = true, + val math: Boolean = true, + val currency: Boolean = true, + val emoticons: Boolean = true, + val ipMarks: Boolean = true +) + +/** + * Dictation command processor for FUTO Keyboard voice input. + * + * Intercepts Whisper transcription output and replaces spoken command phrases + * (e.g., "new line", "caps on", "dollar sign") with the corresponding characters + * or formatting. Runs after [ModelOutputSanitizer] in the voice input pipeline. + * + * Each command category can be independently toggled via [DictationSettings]. + */ +object DictationCommandProcessor { + + private enum class Spacing { NORMAL, NO_SPACE_BEFORE, NO_SPACE_AFTER, NO_SPACE_EITHER } + + private data class Replacement(val text: String, val spacing: Spacing = Spacing.NORMAL) + + // -- Formatting commands -- + private val formattingCommands = mapOf( + "new line" to Replacement("\n", Spacing.NO_SPACE_EITHER), + "new paragraph" to Replacement("\n\n", Spacing.NO_SPACE_EITHER), + "tab key" to Replacement("\t", Spacing.NO_SPACE_EITHER) + ) + + // -- Punctuation & bracket commands -- + private val punctuationCommands = mapOf( + "apostrophe" to Replacement("'", Spacing.NO_SPACE_BEFORE), + "open square bracket" to Replacement("[", Spacing.NO_SPACE_AFTER), + "close square bracket" to Replacement("]", Spacing.NO_SPACE_BEFORE), + "open parenthesis" to Replacement("(", Spacing.NO_SPACE_AFTER), + "close parenthesis" to Replacement(")", Spacing.NO_SPACE_BEFORE), + "open brace" to Replacement("{", Spacing.NO_SPACE_AFTER), + "close brace" to Replacement("}", Spacing.NO_SPACE_BEFORE), + "open angle bracket" to Replacement("<", Spacing.NO_SPACE_AFTER), + "close angle bracket" to Replacement(">", Spacing.NO_SPACE_BEFORE), + "dash" to Replacement("\u2013", Spacing.NO_SPACE_BEFORE), // en-dash – + "ellipsis" to Replacement("\u2026", Spacing.NO_SPACE_BEFORE), // … + "hyphen" to Replacement("-", Spacing.NO_SPACE_BEFORE), + "quote" to Replacement("\u201C", Spacing.NO_SPACE_AFTER), // left double smart quote " + "begin quote" to Replacement("\u201C", Spacing.NO_SPACE_AFTER), + "end quote" to Replacement("\u201D", Spacing.NO_SPACE_BEFORE), // right double smart quote " + "begin single quote" to Replacement("\u2018", Spacing.NO_SPACE_AFTER), // ' + "end single quote" to Replacement("\u2019", Spacing.NO_SPACE_BEFORE), // ' + "period" to Replacement(".", Spacing.NO_SPACE_BEFORE), + "point" to Replacement(".", Spacing.NO_SPACE_BEFORE), + "dot" to Replacement(".", Spacing.NO_SPACE_BEFORE), + "full stop" to Replacement(".", Spacing.NO_SPACE_BEFORE), + "comma" to Replacement(",", Spacing.NO_SPACE_BEFORE), + "exclamation mark" to Replacement("!", Spacing.NO_SPACE_BEFORE), + "question mark" to Replacement("?", Spacing.NO_SPACE_BEFORE), + "colon" to Replacement(":", Spacing.NO_SPACE_BEFORE), + "semicolon" to Replacement(";", Spacing.NO_SPACE_BEFORE) + ) + + // -- Typography symbol commands -- + private val symbolCommands = mapOf( + "ampersand" to Replacement("&"), + "asterisk" to Replacement("*"), + "at sign" to Replacement("@"), + "backslash" to Replacement("\\"), + "forward slash" to Replacement("/"), + "caret" to Replacement("^"), + "center dot" to Replacement("\u00B7"), // · + "large center dot" to Replacement("\u25CF"), // ● + "degree sign" to Replacement("\u00B0"), // ° + "hashtag" to Replacement("#"), + "pound sign" to Replacement("#"), + "percent sign" to Replacement("%"), + "underscore" to Replacement("_"), + "vertical bar" to Replacement("|") + ) + + // -- Math symbol commands -- + private val mathCommands = mapOf( + "equal sign" to Replacement("="), + "greater than sign" to Replacement(">"), + "less than sign" to Replacement("<"), + "minus sign" to Replacement("-"), + "multiplication sign" to Replacement("\u00D7"), // × + "plus sign" to Replacement("+") + ) + + // -- Currency symbol commands -- + private val currencyCommands = mapOf( + "dollar sign" to Replacement("$"), + "cent sign" to Replacement("\u00A2", Spacing.NO_SPACE_BEFORE), // ¢ + "pound sterling sign" to Replacement("\u00A3"), // £ + "euro sign" to Replacement("\u20AC"), // € + "yen sign" to Replacement("\u00A5") // ¥ + ) + + // -- Emoticon commands -- + private val emoticonCommands = mapOf( + "smiley face" to Replacement(":-)"), + "frowny face" to Replacement(":-("), + "winky face" to Replacement(";-)"), + "cross-eyed laughing face" to Replacement("XD") + ) + + // -- Intellectual property mark commands -- + private val ipMarkCommands = mapOf( + "copyright sign" to Replacement("\u00A9"), // © + "registered sign" to Replacement("\u00AE"), // ® + "trademark sign" to Replacement("\u2122") // ™ + ) + + // -- Number word to digit mappings -- + private val numberWords = mapOf( + "zero" to 0, "one" to 1, "two" to 2, "three" to 3, "four" to 4, + "five" to 5, "six" to 6, "seven" to 7, "eight" to 8, "nine" to 9, + "ten" to 10, "eleven" to 11, "twelve" to 12, "thirteen" to 13, + "fourteen" to 14, "fifteen" to 15, "sixteen" to 16, "seventeen" to 17, + "eighteen" to 18, "nineteen" to 19, "twenty" to 20, "thirty" to 30, + "forty" to 40, "fifty" to 50, "sixty" to 60, "seventy" to 70, + "eighty" to 80, "ninety" to 90, "hundred" to 100, "thousand" to 1000 + ) + + // -- Roman numeral mappings -- + private val romanNumerals = mapOf( + 1 to "I", 2 to "II", 3 to "III", 4 to "IV", 5 to "V", + 6 to "VI", 7 to "VII", 8 to "VIII", 9 to "IX", 10 to "X", + 11 to "XI", 12 to "XII", 13 to "XIII", 14 to "XIV", 15 to "XV", + 16 to "XVI", 17 to "XVII", 18 to "XVIII", 19 to "XIX", 20 to "XX", + 30 to "XXX", 40 to "XL", 50 to "L", 60 to "LX", 70 to "LXX", + 80 to "LXXX", 90 to "XC", 100 to "C", 1000 to "M" + ) + + private enum class CapsMode { NONE, TITLE_CASE, ALL_CAPS } + + private val whisperPunct = charArrayOf('.', ',', '!', '?', ';', ':') + + private fun String.stripTrailingPunct(): String = this.trimEnd(*whisperPunct) + + private fun stripTrailingPunct(sb: StringBuilder) { + while (sb.isNotEmpty() && sb.last() in whisperPunct) { + sb.deleteCharAt(sb.length - 1) + } + } + + /** + * Replaces spoken command phrases with their corresponding characters or formatting. + * Returns [text] unchanged if [DictationSettings.enabled] is false. + */ + @JvmStatic + fun process(text: String, settings: DictationSettings): String { + if (!settings.enabled || text.isBlank()) return text + + val leadingSpace = text.takeWhile { it == ' ' } + val trailingSpace = text.takeLastWhile { it == ' ' } + val content = text.trim() + if (content.isEmpty()) return text + + val activeCommands = buildActiveCommands(settings) + + val words = content.split(" ") + val result = StringBuilder() + var capsMode = CapsMode.NONE + var allCapsNextWord = false + var noSpaceMode = false + var numeralNextWord = false + var romanNumeralNextWord = false + var suppressNextSpace = false + var lastWasCommand = false + var i = 0 + + while (i < words.size) { + val matchResult = tryMatchCommand(words, i, activeCommands, settings) + + if (matchResult != null) { + if (matchResult.type != CommandType.NUMERAL && + matchResult.type != CommandType.ROMAN_NUMERAL) { + numeralNextWord = false + romanNumeralNextWord = false + } + + when (matchResult.type) { + CommandType.CAPS_ON -> { + capsMode = CapsMode.TITLE_CASE + i += matchResult.wordsConsumed + continue + } + CommandType.CAPS_OFF -> { + capsMode = CapsMode.NONE + i += matchResult.wordsConsumed + continue + } + CommandType.ALL_CAPS_NEXT -> { + allCapsNextWord = true + i += matchResult.wordsConsumed + continue + } + CommandType.ALL_CAPS_ON -> { + capsMode = CapsMode.ALL_CAPS + i += matchResult.wordsConsumed + continue + } + CommandType.ALL_CAPS_OFF -> { + capsMode = CapsMode.NONE + i += matchResult.wordsConsumed + continue + } + CommandType.NO_SPACE_ON -> { + noSpaceMode = true + i += matchResult.wordsConsumed + continue + } + CommandType.NO_SPACE_OFF -> { + noSpaceMode = false + i += matchResult.wordsConsumed + continue + } + CommandType.NUMERAL -> { + numeralNextWord = true + i += matchResult.wordsConsumed + continue + } + CommandType.ROMAN_NUMERAL -> { + romanNumeralNextWord = true + i += matchResult.wordsConsumed + continue + } + CommandType.REPLACEMENT -> { + val spacing = matchResult.spacing + val skipBefore = noSpaceMode || + spacing == Spacing.NO_SPACE_BEFORE || + spacing == Spacing.NO_SPACE_EITHER || + suppressNextSpace + if (skipBefore && result.isNotEmpty() && !lastWasCommand) { + stripTrailingPunct(result) + } + if (result.isNotEmpty() && !skipBefore) { + result.append(" ") + } + result.append(matchResult.replacement) + suppressNextSpace = spacing == Spacing.NO_SPACE_AFTER || + spacing == Spacing.NO_SPACE_EITHER + lastWasCommand = true + i += matchResult.wordsConsumed + continue + } + } + } + + var word = words[i] + + if (numeralNextWord && settings.formatting) { + val num = numberWords[word.stripTrailingPunct().lowercase()] + if (num != null) { + word = num.toString() + } + numeralNextWord = false + } else if (romanNumeralNextWord && settings.formatting) { + val num = numberWords[word.stripTrailingPunct().lowercase()] + if (num != null) { + val roman = romanNumerals[num] + if (roman != null) { + word = roman + } + } + romanNumeralNextWord = false + } + + word = when (capsMode) { + CapsMode.TITLE_CASE -> { + allCapsNextWord = false + word.replaceFirstChar { it.uppercaseChar() } + } + CapsMode.ALL_CAPS -> { + allCapsNextWord = false + word.uppercase() + } + CapsMode.NONE -> { + if (allCapsNextWord) { + allCapsNextWord = false + word.uppercase() + } else { + word + } + } + } + + if (result.isNotEmpty() && !noSpaceMode && !suppressNextSpace) { + result.append(" ") + } + suppressNextSpace = false + lastWasCommand = false + result.append(word) + i++ + } + + val body = result.toString() + + val prefix = if (leadingSpace.isNotEmpty() && body.isNotEmpty() && + body[0] != '\n' && body[0] != '\t') leadingSpace else "" + val suffix = if (trailingSpace.isNotEmpty() && body.isNotEmpty() && + body.last() != '\n' && body.last() != '\t') trailingSpace else "" + + return prefix + body + suffix + } + + private data class CommandMatch( + val type: CommandType, + val replacement: String, + val spacing: Spacing, + val wordsConsumed: Int + ) + + private enum class CommandType { + REPLACEMENT, + CAPS_ON, CAPS_OFF, + ALL_CAPS_NEXT, ALL_CAPS_ON, ALL_CAPS_OFF, + NO_SPACE_ON, NO_SPACE_OFF, + NUMERAL, ROMAN_NUMERAL + } + + private fun buildActiveCommands(settings: DictationSettings): Map { + val commands = mutableMapOf() + if (settings.formatting) commands.putAll(formattingCommands) + if (settings.punctuation) commands.putAll(punctuationCommands) + if (settings.symbols) commands.putAll(symbolCommands) + if (settings.math) commands.putAll(mathCommands) + if (settings.currency) commands.putAll(currencyCommands) + if (settings.emoticons) commands.putAll(emoticonCommands) + if (settings.ipMarks) commands.putAll(ipMarkCommands) + return commands + } + + private fun tryMatchCommand( + words: List, + startIndex: Int, + activeCommands: Map, + settings: DictationSettings + ): CommandMatch? { + if (settings.capitalization) { + matchStatefulCommand(words, startIndex)?.let { return it } + } + if (settings.formatting) { + matchFormattingStatefulCommand(words, startIndex)?.let { return it } + } + + for (length in minOf(4, words.size - startIndex) downTo 2) { + val phrase = words.subList(startIndex, startIndex + length) + .joinToString(" ") { it.stripTrailingPunct().lowercase() } + val replacement = activeCommands[phrase] + if (replacement != null) { + return CommandMatch(CommandType.REPLACEMENT, replacement.text, replacement.spacing, length) + } + } + + val singleWord = words[startIndex].stripTrailingPunct().lowercase() + val replacement = activeCommands[singleWord] + if (replacement != null) { + return CommandMatch(CommandType.REPLACEMENT, replacement.text, replacement.spacing, 1) + } + + return null + } + + private fun matchStatefulCommand(words: List, startIndex: Int): CommandMatch? { + val remaining = words.size - startIndex + val w0 = words[startIndex].stripTrailingPunct().lowercase() + + if (remaining >= 3) { + val phrase3 = "$w0 ${words[startIndex + 1].stripTrailingPunct().lowercase()} ${words[startIndex + 2].stripTrailingPunct().lowercase()}" + when (phrase3) { + "all caps on" -> return CommandMatch(CommandType.ALL_CAPS_ON, "", Spacing.NORMAL, 3) + "all caps off" -> return CommandMatch(CommandType.ALL_CAPS_OFF, "", Spacing.NORMAL, 3) + } + } + + if (remaining >= 2) { + val phrase2 = "$w0 ${words[startIndex + 1].stripTrailingPunct().lowercase()}" + when (phrase2) { + "caps on" -> return CommandMatch(CommandType.CAPS_ON, "", Spacing.NORMAL, 2) + "caps off" -> return CommandMatch(CommandType.CAPS_OFF, "", Spacing.NORMAL, 2) + "all caps" -> return CommandMatch(CommandType.ALL_CAPS_NEXT, "", Spacing.NORMAL, 2) + } + } + + return null + } + + private fun matchFormattingStatefulCommand(words: List, startIndex: Int): CommandMatch? { + val remaining = words.size - startIndex + val w0 = words[startIndex].stripTrailingPunct().lowercase() + + if (remaining >= 3) { + val phrase3 = "$w0 ${words[startIndex + 1].stripTrailingPunct().lowercase()} ${words[startIndex + 2].stripTrailingPunct().lowercase()}" + when (phrase3) { + "no space on" -> return CommandMatch(CommandType.NO_SPACE_ON, "", Spacing.NORMAL, 3) + "no space off" -> return CommandMatch(CommandType.NO_SPACE_OFF, "", Spacing.NORMAL, 3) + } + } + + if (remaining >= 2 && w0 == "numeral") { + return CommandMatch(CommandType.NUMERAL, "", Spacing.NORMAL, 1) + } + + if (remaining >= 3) { + val phrase2 = "$w0 ${words[startIndex + 1].stripTrailingPunct().lowercase()}" + if (phrase2 == "roman numeral") { + return CommandMatch(CommandType.ROMAN_NUMERAL, "", Spacing.NORMAL, 2) + } + } + + return null + } +} diff --git a/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt b/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt new file mode 100644 index 0000000000..2e14198e14 --- /dev/null +++ b/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt @@ -0,0 +1,626 @@ +package org.futo.inputmethod.latin.uix.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DictationCommandProcessorTest { + + private val allEnabled = DictationSettings() + private val allDisabled = DictationSettings(enabled = false) + + private fun process(text: String, settings: DictationSettings = allEnabled): String { + return DictationCommandProcessor.process(text, settings) + } + + // -- Master toggle -- + + @Test + fun testMasterToggleOff_returnsUnchanged() { + assertEquals("hello new line world", process("hello new line world", allDisabled)) + } + + @Test + fun testEmptyInput_returnsEmpty() { + assertEquals("", process("")) + assertEquals(" ", process(" ")) + } + + @Test + fun testPlainText_passesThrough() { + assertEquals("hello world", process("hello world")) + } + + // -- Formatting commands -- + + @Test + fun testNewLine() { + assertEquals("hello\nworld", process("hello new line world")) + } + + @Test + fun testNewParagraph() { + assertEquals("hello\n\nworld", process("hello new paragraph world")) + } + + @Test + fun testTabKey() { + assertEquals("hello\tworld", process("hello tab key world")) + } + + @Test + fun testNewLineAtStart() { + assertEquals("\nhello", process("new line hello")) + } + + @Test + fun testNewLineAtEnd() { + assertEquals("hello\n", process("hello new line")) + } + + // -- Capitalization commands -- + + @Test + fun testCapsOn_titleCase() { + assertEquals("Hello World", process("caps on hello world")) + } + + @Test + fun testCapsOnOff() { + assertEquals("Hello World back to normal", process("caps on hello world caps off back to normal")) + } + + @Test + fun testAllCaps_singleWord() { + assertEquals("HELLO world", process("all caps hello world")) + } + + @Test + fun testAllCapsOn_multipleWords() { + assertEquals("HELLO WORLD", process("all caps on hello world")) + } + + @Test + fun testAllCapsOnOff() { + assertEquals("HELLO WORLD back to normal", process("all caps on hello world all caps off back to normal")) + } + + @Test + fun testCapsOnDoesNotAffectNextSentenceAfterOff() { + assertEquals("Big small", process("caps on big caps off small")) + } + + // -- Spacing commands -- + + @Test + fun testNoSpaceOn() { + assertEquals("helloworld", process("no space on hello world")) + } + + @Test + fun testNoSpaceOnOff() { + assertEquals("helloworld back to normal", process("no space on hello world no space off back to normal")) + } + + // -- Numeral commands -- + + @Test + fun testNumeral() { + assertEquals("5", process("numeral five")) + } + + @Test + fun testNumeralInContext() { + assertEquals("I have 3 cats", process("I have numeral three cats")) + } + + @Test + fun testRomanNumeral() { + assertEquals("Chapter V", process("caps on chapter caps off roman numeral five")) + } + + @Test + fun testNumeralUnknownWord_passesThrough() { + assertEquals("banana", process("numeral banana")) + } + + // -- Punctuation commands -- + + @Test + fun testApostrophe() { + assertEquals("don't", process("don no space on apostrophe t")) + } + + @Test + fun testBrackets() { + assertEquals("hello [world]", process("hello open square bracket world close square bracket")) + } + + @Test + fun testParentheses() { + assertEquals("hello (world)", process("hello open parenthesis world close parenthesis")) + } + + @Test + fun testBraces() { + assertEquals("hello {world}", process("hello open brace world close brace")) + } + + @Test + fun testAngleBrackets() { + assertEquals("hello ", process("hello open angle bracket world close angle bracket")) + } + + @Test + fun testClosingBracketsNoSpaceBefore() { + // Closing brackets/parens should attach to the preceding word + assertEquals("(hello)", process("open parenthesis hello close parenthesis")) + assertEquals("[hello]", process("open square bracket hello close square bracket")) + assertEquals("{hello}", process("open brace hello close brace")) + assertEquals("", process("open angle bracket hello close angle bracket")) + } + + @Test + fun testSmartQuotes() { + // end quote attaches to preceding word (no space before) + assertEquals("he said \u201Chello\u201D", process("he said begin quote hello end quote")) + } + + @Test + fun testSmartSingleQuotes() { + assertEquals("he said \u2018hello\u2019", process("he said begin single quote hello end single quote")) + } + + @Test + fun testDash() { + assertEquals("hello\u2013world", process("hello no space on dash world")) + } + + @Test + fun testEllipsis() { + assertEquals("hello\u2026", process("hello ellipsis")) + } + + @Test + fun testHyphen() { + assertEquals("well-known", process("well no space on hyphen known")) + } + + @Test + fun testPeriodFallback() { + assertEquals("hello.", process("hello period")) + } + + @Test + fun testCommaFallback() { + assertEquals("hello,", process("hello comma")) + } + + @Test + fun testQuestionMarkFallback() { + assertEquals("hello?", process("hello question mark")) + } + + @Test + fun testExclamationMarkFallback() { + assertEquals("hello!", process("hello exclamation mark")) + } + + @Test + fun testColonFallback() { + assertEquals("hello:", process("hello colon")) + } + + @Test + fun testSemicolonFallback() { + assertEquals("hello;", process("hello semicolon")) + } + + // -- Symbol commands -- + + @Test + fun testAmpersand() { + assertEquals("rock & roll", process("rock ampersand roll")) + } + + @Test + fun testAsterisk() { + assertEquals("hello * world", process("hello asterisk world")) + } + + @Test + fun testAtSign() { + assertEquals("user @ domain", process("user at sign domain")) + } + + @Test + fun testBackslash() { + assertEquals("path \\ file", process("path backslash file")) + } + + @Test + fun testForwardSlash() { + assertEquals("path / file", process("path forward slash file")) + } + + @Test + fun testHashtag() { + assertEquals("# trending", process("hashtag trending")) + } + + @Test + fun testPercentSign() { + assertEquals("100 %", process("100 percent sign")) + } + + @Test + fun testUnderscore() { + assertEquals("snake _ case", process("snake underscore case")) + } + + @Test + fun testVerticalBar() { + assertEquals("a | b", process("a vertical bar b")) + } + + @Test + fun testDegreeSign() { + assertEquals("72 \u00B0", process("72 degree sign")) + } + + @Test + fun testCaret() { + assertEquals("x ^", process("x caret")) + } + + // -- Math commands -- + + @Test + fun testEqualSign() { + assertEquals("x = 5", process("x equal sign 5")) + } + + @Test + fun testPlusSign() { + assertEquals("2 + 3", process("2 plus sign 3")) + } + + @Test + fun testMinusSign() { + assertEquals("5 - 2", process("5 minus sign 2")) + } + + @Test + fun testMultiplicationSign() { + assertEquals("3 \u00D7 4", process("3 multiplication sign 4")) + } + + @Test + fun testGreaterThanSign() { + assertEquals("5 > 3", process("5 greater than sign 3")) + } + + @Test + fun testLessThanSign() { + assertEquals("3 < 5", process("3 less than sign 5")) + } + + // -- Currency commands -- + + @Test + fun testDollarSign() { + assertEquals("$ 100", process("dollar sign 100")) + } + + @Test + fun testCentSign() { + assertEquals("50\u00A2", process("50 cent sign")) + } + + @Test + fun testPoundSterlingSign() { + assertEquals("\u00A3 50", process("pound sterling sign 50")) + } + + @Test + fun testEuroSign() { + assertEquals("\u20AC 100", process("euro sign 100")) + } + + @Test + fun testYenSign() { + assertEquals("\u00A5 1000", process("yen sign 1000")) + } + + // -- Emoticon commands -- + + @Test + fun testSmileyFace() { + assertEquals("hello :-)", process("hello smiley face")) + } + + @Test + fun testFrownyFace() { + assertEquals(":-(", process("frowny face")) + } + + @Test + fun testWinkyFace() { + assertEquals(";-)", process("winky face")) + } + + @Test + fun testCrossEyedLaughingFace() { + assertEquals("XD", process("cross-eyed laughing face")) + } + + // -- IP mark commands -- + + @Test + fun testCopyrightSign() { + assertEquals("\u00A9 2024", process("copyright sign 2024")) + } + + @Test + fun testRegisteredSign() { + assertEquals("Brand \u00AE", process("Brand registered sign")) + } + + @Test + fun testTrademarkSign() { + assertEquals("Name \u2122", process("Name trademark sign")) + } + + // -- Category toggle tests -- + + @Test + fun testFormattingDisabled_passesThrough() { + val settings = DictationSettings(formatting = false) + assertEquals("hello new line world", process("hello new line world", settings)) + } + + @Test + fun testCapitalizationDisabled_passesThrough() { + val settings = DictationSettings(capitalization = false) + assertEquals("hello caps on world", process("hello caps on world", settings)) + } + + @Test + fun testPunctuationDisabled_passesThrough() { + val settings = DictationSettings(punctuation = false) + assertEquals("hello open parenthesis world", process("hello open parenthesis world", settings)) + } + + @Test + fun testSymbolsDisabled_passesThrough() { + val settings = DictationSettings(symbols = false) + assertEquals("hello ampersand world", process("hello ampersand world", settings)) + } + + @Test + fun testMathDisabled_passesThrough() { + val settings = DictationSettings(math = false) + assertEquals("hello equal sign world", process("hello equal sign world", settings)) + } + + @Test + fun testCurrencyDisabled_passesThrough() { + val settings = DictationSettings(currency = false) + assertEquals("hello dollar sign world", process("hello dollar sign world", settings)) + } + + @Test + fun testEmoticonsDisabled_passesThrough() { + val settings = DictationSettings(emoticons = false) + assertEquals("hello smiley face world", process("hello smiley face world", settings)) + } + + @Test + fun testIpMarksDisabled_passesThrough() { + val settings = DictationSettings(ipMarks = false) + assertEquals("hello copyright sign world", process("hello copyright sign world", settings)) + } + + // -- Mixed / complex scenarios -- + + @Test + fun testMixedCommands() { + assertEquals( + "Dear Sir,\nI have $ 100.", + process("caps on dear sir caps off comma new line I have dollar sign 100 period") + ) + } + + @Test + fun testAllCapsWithSymbols() { + assertEquals("HELLO @ world", process("all caps hello at sign world")) + } + + @Test + fun testNoSpaceWithSymbols() { + assertEquals("user@domain", process("no space on user at sign domain")) + } + + @Test + fun testMultipleNewLines() { + assertEquals("a\nb\nc", process("a new line b new line c")) + } + + @Test + fun testCapsModeResetByOff() { + assertEquals("Hello world test", process("caps on hello caps off world test")) + } + + @Test + fun testNumeralFollowedByCommand() { + assertEquals("5\n", process("numeral five new line")) + } + + @Test + fun testCaseInsensitiveMatching() { + assertEquals("hello\nworld", process("hello New Line world")) + } + + @Test + fun testSingleWordInput() { + assertEquals("hello", process("hello")) + } + + @Test + fun testCommandOnly() { + assertEquals("\n", process("new line")) + } + + @Test + fun testConsecutiveCommands() { + assertEquals("\n\n", process("new line new line")) + } + + @Test + fun testFormattingDoesNotAddExtraSpaceAfterNewline() { + // After a newline, the next word should not have a leading space + assertEquals("hello\nworld", process("hello new line world")) + } + + // -- Additional punctuation aliases -- + + @Test + fun testPointFallback() { + assertEquals("hello.", process("hello point")) + } + + @Test + fun testDotFallback() { + assertEquals("hello.", process("hello dot")) + } + + @Test + fun testFullStopFallback() { + assertEquals("hello.", process("hello full stop")) + } + + // -- Additional symbol coverage -- + + @Test + fun testCenterDot() { + assertEquals("hello \u00B7 world", process("hello center dot world")) + } + + @Test + fun testLargeCenterDot() { + assertEquals("hello \u25CF world", process("hello large center dot world")) + } + + @Test + fun testPoundSign() { + assertEquals("# 5", process("pound sign 5")) + } + + // -- Edge cases for dangling state flags -- + + @Test + fun testNumeralAtEnd_passesThrough() { + // "numeral" as last word should pass through (no following word to convert) + assertEquals("numeral", process("numeral")) + } + + @Test + fun testRomanNumeralAtEnd_passesThrough() { + // "roman numeral" as last words should pass through + assertEquals("roman numeral", process("roman numeral")) + } + + @Test + fun testNumeralFollowedByCommand_resetsFlag() { + // "numeral" then a command — the numeral flag should not leak to later words + assertEquals("\nfive", process("numeral new line five")) + } + + @Test + fun testAllCapsNextConsumedInTitleCaseMode() { + // "all caps" then "caps on" — the allCapsNextWord flag should be consumed, not leak + assertEquals("Hello World", process("all caps caps on hello world")) + } + + // -- Punctuation attaches to preceding word -- + + @Test + fun testPunctuationNoSpaceBefore() { + assertEquals("hello. world", process("hello period world")) + assertEquals("hello, world", process("hello comma world")) + assertEquals("hello! world", process("hello exclamation mark world")) + assertEquals("hello? world", process("hello question mark world")) + assertEquals("hello: world", process("hello colon world")) + assertEquals("hello; world", process("hello semicolon world")) + } + + @Test + fun testEllipsisNoSpaceBefore() { + assertEquals("hello\u2026 world", process("hello ellipsis world")) + } + + @Test + fun testDashNoSpaceBefore() { + assertEquals("hello\u2013 world", process("hello dash world")) + } + + // -- Whisper auto-punctuation tolerance -- + + @Test + fun testWhisperCommaOnCommandWord() { + // Whisper may output "hello, new line, world" — commas should not break matching + assertEquals("hello\nworld", process("hello, new line, world")) + } + + @Test + fun testWhisperPeriodOnCommandWord() { + // Whisper may output "hello. New line. World." + assertEquals("hello\nworld.", process("hello. new line. world.")) + } + + @Test + fun testWhisperPunctOnSingleWordCommand() { + // "ampersand," should still match ampersand + assertEquals("hello & world", process("hello ampersand, world")) + } + + @Test + fun testWhisperPunctOnMultiWordCommand() { + // "question mark" has NO_SPACE_BEFORE — attaches to preceding word + assertEquals("hello? world", process("hello question mark, world")) + } + + @Test + fun testWhisperPunctBeforeNewLine() { + // Comma before "new line" should be stripped from output + assertEquals("hello\nworld", process("hello, new line world")) + } + + @Test + fun testWhisperPunctOnStatefulCommands() { + assertEquals("Hello World", process("caps on, hello world")) + } + + // -- Cursor-context whitespace preservation -- + + @Test + fun testLeadingSpacePreserved() { + // Sanitizer adds leading space for cursor context — should be preserved + assertEquals(" hello world", process(" hello world")) + } + + @Test + fun testLeadingSpaceSuppressedBeforeNewline() { + // Leading space makes no sense before a newline + assertEquals("\nhello", process(" new line hello")) + } + + @Test + fun testTrailingSpacePreserved() { + assertEquals("hello world ", process("hello world ")) + } + + @Test + fun testTrailingSpaceSuppressedAfterNewline() { + assertEquals("hello\n", process("hello new line ")) + } +} From b529f19fa39529d06c22cc25ed6399c1f2dd97c9 Mon Sep 17 00:00:00 2001 From: "Donald H." Date: Wed, 11 Feb 2026 10:52:03 -0500 Subject: [PATCH 2/2] Add exclamation point and exclamation as command aliases Whisper often transcribes spoken "exclamation point" as "Exclamation." with capitalization and trailing period. The existing punctuation tolerance handles both; this adds the missing command aliases. --- .../uix/utils/DictationCommandProcessor.kt | 2 ++ .../utils/DictationCommandProcessorTest.kt | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt b/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt index 8d70d59059..57d3b6edd0 100644 --- a/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt +++ b/java/src/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessor.kt @@ -59,6 +59,8 @@ object DictationCommandProcessor { "full stop" to Replacement(".", Spacing.NO_SPACE_BEFORE), "comma" to Replacement(",", Spacing.NO_SPACE_BEFORE), "exclamation mark" to Replacement("!", Spacing.NO_SPACE_BEFORE), + "exclamation point" to Replacement("!", Spacing.NO_SPACE_BEFORE), + "exclamation" to Replacement("!", Spacing.NO_SPACE_BEFORE), "question mark" to Replacement("?", Spacing.NO_SPACE_BEFORE), "colon" to Replacement(":", Spacing.NO_SPACE_BEFORE), "semicolon" to Replacement(";", Spacing.NO_SPACE_BEFORE) diff --git a/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt b/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt index 2e14198e14..78ddd7c2d1 100644 --- a/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt +++ b/java/test/org/futo/inputmethod/latin/uix/utils/DictationCommandProcessorTest.kt @@ -205,6 +205,28 @@ class DictationCommandProcessorTest { assertEquals("hello!", process("hello exclamation mark")) } + @Test + fun testExclamationPoint() { + assertEquals("hello!", process("hello exclamation point")) + } + + @Test + fun testExclamationAlone() { + assertEquals("hello!", process("hello exclamation")) + } + + @Test + fun testWhisperExclamationWithPeriodAndCaps() { + // Whisper outputs "this is a sentence. Exclamation." for spoken "exclamation point" + assertEquals("this is a sentence!", process("this is a sentence. Exclamation.")) + } + + @Test + fun testWhisperExclamationTrailingPeriod() { + // Whisper outputs "another sentence spoken exclamation." + assertEquals("another sentence spoken!", process("another sentence spoken exclamation.")) + } + @Test fun testColonFallback() { assertEquals("hello:", process("hello colon"))