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 <jatinkrmalik@users.noreply.github.com>

* 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 <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-29 01:39:15 +05:30
committed by GitHub
co-authored by Jatin K Malik Cursor Agent
parent 8c76578200
commit 7422bf927e
6 changed files with 320 additions and 0 deletions
+7
View File
@@ -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)
@@ -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
+1
View File
@@ -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,
+30
View File
@@ -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", {})
+117
View File
@@ -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()
+137
View File
@@ -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")