From 7422bf927ebe6982f14897854407341601c63496 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 28 Jul 2026 13:09:15 -0700 Subject: [PATCH] feat: auto-capitalize sentences after punctuation (#554) * feat: auto-capitalize sentences after punctuation Add capitalize_sentences() function that uppercases the first letter of dictated text and after sentence-ending punctuation (., !, ?) followed by whitespace. Preserves URLs, decimals, and abbreviations without trailing space. - Config option text_injection.auto_capitalize (default: true) - Settings toggle in General tab to opt out - 17 tests covering edge cases Fixes #553 * style: black-format auto-capitalize helpers for CI lint Fixes the Lint Python check failure on the auto-capitalize PR. * refactor: make auto-capitalize Vosk-only with clear UI indication - Gate auto-capitalize feature to Vosk engine only (Whisper outputs proper casing) - Update settings tooltip to explain Vosk-only behavior - Update subtitle to show '(Vosk only)' for user clarity * test(main): cover Vosk-only auto-capitalize injection path Add callback wiring tests for enabled/disabled auto-capitalize on Vosk and the Whisper skip path so codecov patch coverage clears the 80% bar. Co-authored-by: Jatin K Malik * refactor(settings): move Auto-Capitalize to Recognition tab Auto-capitalize is dictation output post-processing, not application lifecycle behavior, so it belongs alongside Voice Commands rather than with autostart/clipboard settings in General. Co-authored-by: Jatin K Malik --------- Co-authored-by: Cursor Agent Co-authored-by: Jatin K Malik --- src/vocalinux/main.py | 7 + .../speech_recognition/command_processor.py | 28 ++++ src/vocalinux/ui/config_manager.py | 1 + src/vocalinux/ui/settings_dialog.py | 30 ++++ tests/test_capitalize_sentences.py | 117 +++++++++++++++ tests/test_main.py | 137 ++++++++++++++++++ 6 files changed, 320 insertions(+) create mode 100644 tests/test_capitalize_sentences.py diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index eb3362e..c7e5fa2 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -470,6 +470,13 @@ def main(): text_to_inject = " " + text_to_inject logger.debug("Added space separator before new segment") + # Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing) + auto_capitalize = config_manager.get("text_injection", "auto_capitalize") + if auto_capitalize and speech_engine.engine == "vosk": + from vocalinux.speech_recognition.command_processor import capitalize_sentences + + text_to_inject = capitalize_sentences(text_to_inject) + success = text_system.inject_text(text_to_inject) if success: action_handler.set_last_injected_text(text_to_inject) diff --git a/src/vocalinux/speech_recognition/command_processor.py b/src/vocalinux/speech_recognition/command_processor.py index 0217fb5..dc7597b 100644 --- a/src/vocalinux/speech_recognition/command_processor.py +++ b/src/vocalinux/speech_recognition/command_processor.py @@ -304,3 +304,31 @@ class CommandProcessor: processed_text = "" return processed_text, actions + + +def capitalize_sentences(text: str) -> str: + """Capitalize the first letter of each sentence. + + Uppercases the first character of the string and any lowercase letter + that follows sentence-ending punctuation (``.``, ``!``, ``?``) plus + whitespace. Leaves URLs, decimals, and abbreviations without trailing + space untouched. + + Args: + text: The text to capitalize. + + Returns: + The text with sentence-initial letters uppercased. + """ + if not text: + return text + + # Capitalize first character + result = text[0].upper() + text[1:] + + # Capitalize after sentence-ending punctuation + whitespace + result = re.sub( + r"([.!?])(\s+)([a-z])", lambda m: m.group(1) + m.group(2) + m.group(3).upper(), result + ) + + return result diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 0cbb208..932a865 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -74,6 +74,7 @@ DEFAULT_CONFIG = { }, "text_injection": { "copy_to_clipboard": False, # Disabled by default; users can enable in Settings + "auto_capitalize": True, # Capitalize first letter of each sentence }, "advanced": { "power_user_mode": False, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index b493311..57f4a47 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1910,6 +1910,18 @@ class SettingsDialog(Gtk.Dialog): logger.info(f"Copy to clipboard {'enabled' if enabled else 'disabled'}") return False + def _on_auto_capitalize_toggled(self, widget, state): + """Handle toggle of the auto-capitalize switch.""" + if self._initializing or self._applying_settings: + return False + + enabled = bool(state) + logger.info(f"Auto-capitalize toggled: {enabled}") + self.config_manager.set("text_injection", "auto_capitalize", enabled) + self.config_manager.save_settings() + logger.info(f"Auto-capitalize {'enabled' if enabled else 'disabled'}") + return False + def _on_sound_effects_toggled(self, widget, state): if self._initializing or self._applying_settings: return False @@ -2161,6 +2173,20 @@ class SettingsDialog(Gtk.Dialog): ) output_group.add_row(voice_commands_row) + self.auto_capitalize_switch = Gtk.Switch() + self.auto_capitalize_switch.set_tooltip_text( + "Automatically capitalize the first letter of each sentence. " + "Works after sentence-ending punctuation (period, exclamation, question mark). " + "Only applies to Vosk engine - Whisper models output proper capitalization automatically." + ) + auto_capitalize_row = PreferenceRow( + title="Auto-Capitalize Sentences", + subtitle="Capitalize first letter after punctuation (Vosk only)", + widget=self.auto_capitalize_switch, + keywords=("capitalization", "casing", "sentence"), + ) + output_group.add_row(auto_capitalize_row) + self.copy_to_clipboard_switch = Gtk.Switch() self.copy_to_clipboard_switch.set_tooltip_text( "Copy recognized text to clipboard after each transcription. " @@ -2176,6 +2202,7 @@ class SettingsDialog(Gtk.Dialog): self.recognition_settings_tab.pack_start(output_group, False, False, 0) self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled) + self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled) if not silero_active: vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -2208,6 +2235,7 @@ class SettingsDialog(Gtk.Dialog): self.vad_spin.connect("value-changed", self._on_vad_changed) self.silence_spin.connect("value-changed", self._on_silence_changed) self.voice_commands_switch.connect("state-set", self._on_voice_commands_toggled) + self.auto_capitalize_switch.connect("state-set", self._on_auto_capitalize_toggled) def _build_shortcuts_section(self): """Build the Keyboard Shortcuts section.""" @@ -3102,10 +3130,12 @@ class SettingsDialog(Gtk.Dialog): autostart_enabled = general_settings.get("autostart", False) start_minimized = ui_settings.get("start_minimized", False) copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False) + auto_capitalize = text_injection_settings.get("auto_capitalize", True) self.autostart_switch.set_active(autostart_enabled) self.start_minimized_switch.set_active(start_minimized) self.copy_to_clipboard_switch.set_active(copy_to_clipboard) + self.auto_capitalize_switch.set_active(auto_capitalize) self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled()) auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {}) diff --git a/tests/test_capitalize_sentences.py b/tests/test_capitalize_sentences.py new file mode 100644 index 0000000..de384ea --- /dev/null +++ b/tests/test_capitalize_sentences.py @@ -0,0 +1,117 @@ +"""Tests for capitalize_sentences function.""" + +import unittest + +from vocalinux.speech_recognition.command_processor import capitalize_sentences + + +class TestCapitalizeSentences(unittest.TestCase): + """Test cases for sentence capitalization.""" + + def test_empty_string(self): + """Empty string returns empty.""" + self.assertEqual(capitalize_sentences(""), "") + + def test_none_input(self): + """None returns None.""" + self.assertIsNone(capitalize_sentences(None)) + + def test_single_word(self): + """Single lowercase word gets capitalized.""" + self.assertEqual(capitalize_sentences("hello"), "Hello") + + def test_already_capitalized(self): + """Already capitalized text stays the same.""" + self.assertEqual(capitalize_sentences("Hello world"), "Hello world") + + def test_two_sentences_period(self): + """Capitalize after period + space.""" + self.assertEqual( + capitalize_sentences("hello world. goodbye world"), + "Hello world. Goodbye world", + ) + + def test_multiple_sentences_mixed_punctuation(self): + """Capitalize after period, question mark, exclamation.""" + self.assertEqual( + capitalize_sentences("what? really! yes. ok"), + "What? Really! Yes. Ok", + ) + + def test_url_preserved(self): + """URLs without trailing space are not mangled.""" + self.assertEqual( + capitalize_sentences("visit example.com for info"), + "Visit example.com for info", + ) + + def test_decimal_preserved(self): + """Decimal numbers are not treated as sentence boundaries.""" + self.assertEqual( + capitalize_sentences("the value is 3.14 approximately"), + "The value is 3.14 approximately", + ) + + def test_abbreviation_preserved(self): + """Abbreviations without trailing space are preserved.""" + self.assertEqual( + capitalize_sentences("e.g. this is an example"), + "E.g. This is an example", + ) + + def test_number_after_period(self): + """Numbers after punctuation are not capitalized (they're not letters).""" + self.assertEqual( + capitalize_sentences("42. 100 people showed up"), + "42. 100 people showed up", + ) + + def test_newlines(self): + """Newlines are treated as whitespace.""" + self.assertEqual( + capitalize_sentences("hello.\nworld"), + "Hello.\nWorld", + ) + + def test_multiple_spaces(self): + """Multiple spaces after punctuation still trigger capitalization.""" + self.assertEqual( + capitalize_sentences("hello. world"), + "Hello. World", + ) + + def test_no_space_after_period(self): + """No space after period means no capitalization (like in URLs).""" + self.assertEqual( + capitalize_sentences("test.case"), + "Test.case", + ) + + def test_all_uppercase(self): + """All uppercase text stays uppercase.""" + self.assertEqual( + capitalize_sentences("HELLO WORLD. GOODBYE WORLD"), + "HELLO WORLD. GOODBYE WORLD", + ) + + def test_mixed_case_after_punctuation(self): + """Only lowercase letters after punctuation get capitalized.""" + self.assertEqual( + capitalize_sentences("hello. WORLD. goodbye"), + "Hello. WORLD. Goodbye", + ) + + def test_single_character(self): + """Single character gets capitalized.""" + self.assertEqual(capitalize_sentences("a"), "A") + + def test_starts_with_number(self): + """Text starting with a number is handled correctly.""" + self.assertEqual( + capitalize_sentences("42 things happened. then more."), + "42 things happened. Then more.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py index 5898b39..c4c0287 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -325,6 +325,143 @@ class TestMainModule(unittest.TestCase): text_callback("Next session") mock_text_instance.inject_text.assert_called_once_with("Next session") + def _run_main_and_get_text_callback( + self, + *, + engine: str, + auto_capitalize: bool, + mock_config_manager, + mock_tray, + mock_text, + mock_speech, + mock_check_deps, + ): + """Boot main() with mocked deps and return the registered text callback.""" + mock_check_deps.return_value = True + + mock_config_instance = MagicMock() + mock_config_instance.get_settings.return_value = { + "speech_recognition": {}, + "general": {"first_run": False}, + } + mock_config_instance.get.side_effect = lambda section, key, default=None: ( + auto_capitalize if section == "text_injection" and key == "auto_capitalize" else default + ) + mock_config_manager.return_value = mock_config_instance + + mock_speech_instance = MagicMock() + mock_speech_instance.engine = engine + mock_text_instance = MagicMock() + mock_text_instance.inject_text.return_value = True + mock_tray_instance = MagicMock() + + mock_speech.return_value = mock_speech_instance + mock_text.return_value = mock_text_instance + mock_tray.return_value = mock_tray_instance + + with patch("vocalinux.main.parse_arguments") as mock_parse: + mock_args = MagicMock() + mock_args.debug = False + mock_args.model = "medium" + mock_args.engine = engine + mock_args.language = "en-us" + mock_args.wayland = False + mock_args.start_minimized = False + mock_parse.return_value = mock_args + + with patch("sys.argv", ["vocalinux", "--engine", engine]): + main() + + text_callback = mock_speech_instance.register_text_callback.call_args.args[0] + return text_callback, mock_text_instance + + @patch("vocalinux.main.check_dependencies") + @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + @patch("vocalinux.text_injection.text_injector.TextInjector") + @patch("vocalinux.ui.tray_indicator.TrayIndicator") + @patch("vocalinux.ui.config_manager.ConfigManager") + @patch("vocalinux.ui.logging_manager.initialize_logging") + def test_vosk_auto_capitalize_applies_to_injected_text( + self, + mock_init_logging, + mock_config_manager, + mock_tray, + mock_text, + mock_speech, + mock_check_deps, + ): + """Vosk engine capitalizes sentences when auto_capitalize is enabled.""" + text_callback, mock_text_instance = self._run_main_and_get_text_callback( + engine="vosk", + auto_capitalize=True, + mock_config_manager=mock_config_manager, + mock_tray=mock_tray, + mock_text=mock_text, + mock_speech=mock_speech, + mock_check_deps=mock_check_deps, + ) + + text_callback("hello world. goodbye") + mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye") + + @patch("vocalinux.main.check_dependencies") + @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + @patch("vocalinux.text_injection.text_injector.TextInjector") + @patch("vocalinux.ui.tray_indicator.TrayIndicator") + @patch("vocalinux.ui.config_manager.ConfigManager") + @patch("vocalinux.ui.logging_manager.initialize_logging") + def test_vosk_auto_capitalize_can_be_disabled( + self, + mock_init_logging, + mock_config_manager, + mock_tray, + mock_text, + mock_speech, + mock_check_deps, + ): + """Vosk leaves casing unchanged when auto_capitalize is disabled.""" + text_callback, mock_text_instance = self._run_main_and_get_text_callback( + engine="vosk", + auto_capitalize=False, + mock_config_manager=mock_config_manager, + mock_tray=mock_tray, + mock_text=mock_text, + mock_speech=mock_speech, + mock_check_deps=mock_check_deps, + ) + + text_callback("hello world. goodbye") + mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye") + + @patch("vocalinux.main.check_dependencies") + @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + @patch("vocalinux.text_injection.text_injector.TextInjector") + @patch("vocalinux.ui.tray_indicator.TrayIndicator") + @patch("vocalinux.ui.config_manager.ConfigManager") + @patch("vocalinux.ui.logging_manager.initialize_logging") + def test_whisper_skips_auto_capitalize( + self, + mock_init_logging, + mock_config_manager, + mock_tray, + mock_text, + mock_speech, + mock_check_deps, + ): + """Whisper engines keep model casing even when auto_capitalize is on.""" + text_callback, mock_text_instance = self._run_main_and_get_text_callback( + engine="whisper_cpp", + auto_capitalize=True, + mock_config_manager=mock_config_manager, + mock_tray=mock_tray, + mock_text=mock_text, + mock_speech=mock_speech, + mock_check_deps=mock_check_deps, + ) + + text_callback("hello world. goodbye") + mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye") + @patch("vocalinux.main.check_dependencies") @patch("vocalinux.ui.action_handler.ActionHandler") @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")