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:
co-authored by
Jatin K Malik
Cursor Agent
parent
8c76578200
commit
7422bf927e
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user