From a33c0d1b4cae1aa630e0ee5a25a169b75d5682cb Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Fri, 18 Apr 2025 23:36:49 -0500 Subject: [PATCH] Simplify Keyboard Shortcuts to Exclusive Double-tap Ctrl Implementation (#9) * Implement double-tap Ctrl keyboard shortcut feature * Simplify keyboard shortcuts to only use double-tap Ctrl * Update docs * Fix syntax * Fix CI build by adding portaudio19-dev dependency --- .github/workflows/ci.yml | 3 +- README.md | 8 +- docs/INSTALL.md | 25 +++- docs/USER_GUIDE.md | 13 +-- setup.py | 8 +- src/ui/config_manager.py | 2 +- src/ui/keyboard_shortcuts.py | 109 ++++++------------ tests/test_keyboard_shortcuts.py | 190 ++++++++++--------------------- 8 files changed, 133 insertions(+), 225 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 983ef81..2009597 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,8 @@ jobs: libgirepository1.0-dev \ gobject-introspection \ libcairo-gobject2 \ - gir1.2-gtk-3.0 + gir1.2-gtk-3.0 \ + portaudio19-dev - name: Cache apt packages uses: actions/cache@v3 diff --git a/README.md b/README.md index 294921d..3bea461 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A seamless voice dictation system for Linux, comparable to the built-in solution Vocalinux provides a user-friendly speech-to-text solution for Linux users with: -- Activation via customizable keyboard shortcuts (default: Alt+Shift+V) +- Activation via double-tap Ctrl keyboard shortcut - Real-time transcription with minimal latency - Universal compatibility across applications - Offline operation for privacy and reliability @@ -118,10 +118,10 @@ vocalinux --model medium ### Using Voice Dictation -1. Press the keyboard shortcut (default: Alt+Shift+V) to start recording +1. Double-tap the Ctrl key to start recording 2. You'll hear a start sound when recording begins 3. Speak clearly into your microphone -4. Press the same shortcut again or pause speaking to stop recording +4. Double-tap Ctrl again or pause speaking to stop recording 5. You'll hear a stop sound when recording ends ### Using Voice Commands @@ -153,7 +153,7 @@ Configuration is stored in `~/.config/vocalinux/config.json` and includes: "timeout": 2.0 }, "shortcuts": { - "toggle_recognition": "alt+shift+v" + "toggle_recognition": "ctrl+ctrl" }, "ui": { "start_minimized": false, diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 66ad31d..749eea2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -13,6 +13,9 @@ This guide provides instructions for installing and setting up Vocalinux. ### 1. Install System Dependencies ```bash +# Required system dependencies for PyAudio and PyGObject +sudo apt install portaudio19-dev libgirepository1.0-dev libcairo2-dev pkg-config python3-dev + # For X11 environments sudo apt install xdotool python3-pip python3-gi gir1.2-appindicator3-0.1 @@ -32,19 +35,27 @@ cd vocalinux ### 3. Install Python Dependencies ```bash +# Create a virtual environment (recommended) +python3 -m venv venv +source venv/bin/activate + # Basic installation -pip3 install --user . +pip install --upgrade pip +pip install . # With Whisper support (requires more resources) -pip3 install --user ".[whisper]" +pip install ".[whisper]" # For development -pip3 install --user ".[dev]" +pip install ".[dev]" ``` ### 4. Run the Application ```bash +# If using virtual environment, make sure it's activated +source venv/bin/activate + # Basic usage vocalinux @@ -74,6 +85,14 @@ Note that larger models require more RAM and may have longer initialization time ## Troubleshooting +### Build errors related to PyAudio or PyGObject + +If you encounter errors like `portaudio.h: No such file or directory` or issues with GTK/GObject, make sure you've installed all the required system dependencies: + +```bash +sudo apt install portaudio19-dev libgirepository1.0-dev libcairo2-dev pkg-config python3-dev +``` + ### No audio input detected - Check that your microphone is connected and working diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0981077..68c25b0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -12,9 +12,9 @@ After installing Vocalinux (see the [Installation Guide](INSTALL.md)), you can s 1. **Launch the application**: Run `vocalinux` in a terminal or launch it from your application menu 2. **Find the tray icon**: Look for the microphone icon in your system tray -3. **Start voice typing**: Click the tray icon and select "Start Voice Typing" from the menu +3. **Start voice typing**: Click the tray icon and select "Start Voice Typing" from the menu, or use the double-tap Ctrl keyboard shortcut 4. **Speak clearly**: As you speak, your words will be transcribed into the currently focused application -5. **Stop voice typing**: Click the tray icon and select "Stop Voice Typing" when you're done +5. **Stop voice typing**: Click the tray icon and select "Stop Voice Typing" when you're done, or use the double-tap Ctrl keyboard shortcut again ### Understanding the Status Icons @@ -50,13 +50,12 @@ Vocalinux supports several commands that you can speak to control formatting: ## Customization -### Keyboard Shortcuts +### Keyboard Shortcut -Vocalinux supports global keyboard shortcuts for starting and stopping voice typing: +Vocalinux uses a double-tap Ctrl keyboard shortcut for starting and stopping voice typing: -1. Open the application settings from the tray icon menu -2. Go to the "Shortcuts" tab -3. Set your preferred keyboard shortcut (default is Alt+Shift+V) +- **Double-tap Ctrl**: Quickly press the Ctrl key twice to toggle voice typing on or off +- The time between taps should be less than 0.3 seconds to be recognized as a double-tap ### Model Settings diff --git a/setup.py b/setup.py index f37de4d..0681db3 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 from setuptools import setup, find_packages +# This package requires several system dependencies that must be installed manually: +# For PyAudio: portaudio19-dev (on Ubuntu/Debian) +# For PyGObject: libgirepository1.0-dev, libcairo2-dev, pkg-config, python3-dev + setup( name="vocalinux", version="0.1.0", @@ -17,7 +21,8 @@ setup( "PyGObject", # For GTK UI "requests", # For downloading models "tqdm", # For progress bars during downloads - "numpy" # For numerical operations + "numpy", # For numerical operations + "pyaudio", # For audio input/output ], extras_require={ "whisper": ["whisper", "torch"], # Optional Whisper AI support @@ -27,6 +32,7 @@ setup( "black", "isort", "flake8", + "pre-commit", # For pre-commit hooks ], }, entry_points={ diff --git a/src/ui/config_manager.py b/src/ui/config_manager.py index 9d72b29..b767a83 100644 --- a/src/ui/config_manager.py +++ b/src/ui/config_manager.py @@ -26,7 +26,7 @@ DEFAULT_CONFIG = { "timeout": 2.0, # Seconds of silence before stopping recognition }, "shortcuts": { - "toggle_recognition": "alt+shift+v", + "toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl }, "ui": { "start_minimized": False, diff --git a/src/ui/keyboard_shortcuts.py b/src/ui/keyboard_shortcuts.py index 35fdba8..8958410 100644 --- a/src/ui/keyboard_shortcuts.py +++ b/src/ui/keyboard_shortcuts.py @@ -2,13 +2,13 @@ Keyboard shortcut manager for Vocalinux. This module provides global keyboard shortcut functionality to -start/stop speech recognition with a keystroke. +start/stop speech recognition with a double-tap of the Ctrl key. """ import logging import threading import time -from typing import Callable, Dict, Optional, Tuple +from typing import Callable # Try to import X11 keyboard libraries first try: @@ -25,29 +25,27 @@ class KeyboardShortcutManager: """ Manages global keyboard shortcuts for the application. - This class allows registering global keyboard shortcuts that work - across the desktop environment. + This class allows registering the double-tap Ctrl shortcut to + toggle voice typing on and off across the desktop environment. """ def __init__(self): """Initialize the keyboard shortcut manager.""" - self.shortcuts = {} self.listener = None self.active = False self.last_trigger_time = 0 # Track last trigger time to prevent double triggers + # Double-tap tracking variables + self.last_ctrl_press_time = 0 + self.double_tap_callback = None + self.double_tap_threshold = 0.3 # seconds between taps to count as double-tap + if not KEYBOARD_AVAILABLE: logger.error( "Keyboard shortcut libraries not available. Shortcuts will not work." ) return - # Default shortcut for toggling voice typing (Alt+Shift+V) - self.default_shortcut = ( - {keyboard.Key.alt, keyboard.Key.shift}, - keyboard.KeyCode.from_char("v"), - ) - def start(self): """Start listening for keyboard shortcuts.""" if not KEYBOARD_AVAILABLE: @@ -97,42 +95,15 @@ class KeyboardShortcutManager: finally: self.listener = None - def register_shortcut(self, modifiers: set, key, callback: Callable): - """ - Register a keyboard shortcut. - - Args: - modifiers: Set of modifier keys (e.g., {keyboard.Key.alt, keyboard.Key.shift}) - key: The main key (e.g., keyboard.KeyCode.from_char('v')) - callback: Function to call when the shortcut is pressed - """ - shortcut_key = (frozenset(modifiers), key) - self.shortcuts[shortcut_key] = callback - - # Create readable description of the shortcut for logging - mod_names = [self._get_key_name(mod) for mod in modifiers] - key_name = self._get_key_name(key) - shortcut_desc = "+".join(mod_names + [key_name]) - logger.info(f"Registered shortcut: {shortcut_desc}") - def register_toggle_callback(self, callback: Callable): """ - Register a callback for the default toggle shortcut. + Register a callback for the double-tap Ctrl shortcut. Args: - callback: Function to call when the toggle shortcut is pressed + callback: Function to call when the double-tap Ctrl is pressed """ - modifiers, key = self.default_shortcut - self.register_shortcut(modifiers, key, callback) - - def _get_key_name(self, key): - """Get a readable name for a key object.""" - if hasattr(key, "char") and key.char: - return key.char.upper() - elif hasattr(key, "name"): - return key.name - else: - return str(key) + self.double_tap_callback = callback + logger.info("Registered shortcut: Double-tap Ctrl") def _on_press(self, key): """ @@ -142,50 +113,34 @@ class KeyboardShortcutManager: key: The pressed key """ try: - # Add to currently pressed keys (only for modifier keys) + # Check for double-tap Ctrl + normalized_key = self._normalize_modifier_key(key) + if normalized_key == keyboard.Key.ctrl: + current_time = time.time() + if current_time - self.last_ctrl_press_time < self.double_tap_threshold: + # This is a double-tap Ctrl + if ( + self.double_tap_callback + and current_time - self.last_trigger_time > 0.5 + ): + logger.debug("Double-tap Ctrl detected") + self.last_trigger_time = current_time + # Run callback in a separate thread to avoid blocking + threading.Thread( + target=self.double_tap_callback, daemon=True + ).start() + self.last_ctrl_press_time = current_time + + # Add to currently pressed modifier keys (only for tracking Ctrl) if key in { - keyboard.Key.alt, - keyboard.Key.alt_l, - keyboard.Key.alt_r, - keyboard.Key.shift, - keyboard.Key.shift_l, - keyboard.Key.shift_r, keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r, - keyboard.Key.cmd, - keyboard.Key.cmd_l, - keyboard.Key.cmd_r, }: # Normalize left/right variants normalized_key = self._normalize_modifier_key(key) self.current_keys.add(normalized_key) - # Check for shortcuts (only once per 500ms to prevent double triggers) - current_time = time.time() - if current_time - self.last_trigger_time > 0.5: # 500ms debounce - for (modifiers, trigger_key), callback in self.shortcuts.items(): - # Normalize the trigger key for character keys - if isinstance(trigger_key, keyboard.KeyCode) and hasattr( - trigger_key, "char" - ): - if isinstance(key, keyboard.KeyCode) and hasattr(key, "char"): - key_matches = key.char.lower() == trigger_key.char.lower() - else: - key_matches = False - else: - key_matches = key == trigger_key - - # Check if modifiers match and key matches - normalized_modifiers = { - self._normalize_modifier_key(m) for m in modifiers - } - if self.current_keys == normalized_modifiers and key_matches: - logger.debug(f"Shortcut triggered: {modifiers} + {trigger_key}") - self.last_trigger_time = current_time - - # Run callback in a separate thread to avoid blocking - threading.Thread(target=callback, daemon=True).start() except Exception as e: logger.error(f"Error in keyboard shortcut handling: {e}") diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index 53a4624..5281afa 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -43,11 +43,6 @@ class TestKeyboardShortcuts(unittest.TestCase): self.mock_keyboard.Key.cmd_l = "Key.cmd_l" self.mock_keyboard.Key.cmd_r = "Key.cmd_r" - # Mock KeyCode.from_char - key_v = MagicMock() - key_v.char = "v" - self.mock_keyboard.KeyCode.from_char.return_value = key_v - # Create mock Listener self.mock_listener = MagicMock() self.mock_listener.is_alive.return_value = True @@ -63,12 +58,10 @@ class TestKeyboardShortcuts(unittest.TestCase): def test_init(self): """Test initialization of the keyboard shortcut manager.""" - # Verify default shortcut is set correctly - modifiers, key = self.ksm.default_shortcut - self.assertEqual( - modifiers, {self.mock_keyboard.Key.alt, self.mock_keyboard.Key.shift} - ) - self.assertEqual(key.char, "v") + # Verify double-tap threshold is set + self.assertEqual(self.ksm.double_tap_threshold, 0.3) + # Verify double-tap callback is initially None + self.assertIsNone(self.ksm.double_tap_callback) def test_start_listener(self): """Test starting the keyboard listener.""" @@ -137,39 +130,16 @@ class TestKeyboardShortcuts(unittest.TestCase): if hasattr(self.ksm, "listener") and self.ksm.listener: self.mock_listener.stop.assert_not_called() - def test_register_shortcut(self): - """Test registering keyboard shortcuts.""" - # Create mock callback and shortcut - callback = MagicMock() - modifiers = {self.mock_keyboard.Key.ctrl, self.mock_keyboard.Key.shift} - key = MagicMock() - key.char = "s" - - # Mock _get_key_name to avoid errors - self.ksm._get_key_name = MagicMock(return_value="S") - - # Register the shortcut - self.ksm.register_shortcut(modifiers, key, callback) - - # Verify it was registered - shortcut_key = (frozenset(modifiers), key) - self.assertEqual(self.ksm.shortcuts[shortcut_key], callback) - def test_register_toggle_callback(self): - """Test registering toggle callback.""" + """Test registering toggle callback with double-tap shortcut.""" # Create mock callback callback = MagicMock() - # Mock _get_key_name to avoid errors - self.ksm._get_key_name = MagicMock(return_value="V") - # Register as toggle callback self.ksm.register_toggle_callback(callback) - # Verify it was registered with default shortcut - modifiers, key = self.ksm.default_shortcut - shortcut_key = (frozenset(modifiers), key) - self.assertEqual(self.ksm.shortcuts[shortcut_key], callback) + # Verify it was registered as double-tap callback + self.assertEqual(self.ksm.double_tap_callback, callback) def test_key_press_modifier(self): """Test handling a modifier key press.""" @@ -182,11 +152,41 @@ class TestKeyboardShortcuts(unittest.TestCase): # Make sure current_keys is set and initially empty self.ksm.current_keys = set() - # Simulate pressing Alt - on_press(self.mock_keyboard.Key.alt) + # Simulate pressing Ctrl + on_press(self.mock_keyboard.Key.ctrl) - # Verify Alt was added to current keys - self.assertIn(self.mock_keyboard.Key.alt, self.ksm.current_keys) + # Verify Ctrl was added to current keys + self.assertIn(self.mock_keyboard.Key.ctrl, self.ksm.current_keys) + + def test_double_tap_ctrl(self): + """Test double-tap Ctrl detection.""" + # Initialize and start to set up the listener + self.ksm.start() + + # Get the on_press handler + on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] + + # Register mock callback for double-tap Ctrl + callback = MagicMock() + self.ksm.register_toggle_callback(callback) + + # Set up initial state + self.ksm.last_ctrl_press_time = ( + time.time() - 0.2 + ) # Recent press (within threshold) + self.ksm.last_trigger_time = 0 # No recent triggers + + # Simulate second Ctrl press (should trigger callback) + on_press(self.mock_keyboard.Key.ctrl) + + # Verify callback was triggered + callback.assert_called_once() + + # Reset and test when press is outside threshold (not a double-tap) + callback.reset_mock() + self.ksm.last_ctrl_press_time = time.time() - 0.5 # Outside threshold + on_press(self.mock_keyboard.Key.ctrl) + callback.assert_not_called() def test_normalize_modifier_keys(self): """Test normalizing left/right modifier keys.""" @@ -210,54 +210,31 @@ class TestKeyboardShortcuts(unittest.TestCase): self.mock_keyboard.Key.alt, ) - def test_get_key_name(self): - """Test getting readable name for a key.""" - # Create a key with .char attribute - char_key = MagicMock() - char_key.char = "a" - self.assertEqual(self.ksm._get_key_name(char_key), "A") - - # Create a key with .name attribute but no .char - name_key = MagicMock() - name_key.name = "shift" - name_key.char = None - self.assertEqual(self.ksm._get_key_name(name_key), "shift") - - # Create a key with neither .char nor .name - other_key = "other_key" - self.assertEqual(self.ksm._get_key_name(other_key), "other_key") - - def test_key_press_shortcut_trigger(self): - """Test triggering a shortcut.""" + def test_double_tap_ctrl_debounce(self): + """Test that double-tap Ctrl has debounce protection.""" # Initialize and start to set up the listener self.ksm.start() - # Create a simplified on_press function to avoid complex type checking - # This focuses on testing the shortcut logic, not the key detection - def simplified_on_press(key): - # Simply check for our specific key and trigger callback - shortcut_key = (frozenset({self.mock_keyboard.Key.ctrl}), key) - if shortcut_key in self.ksm.shortcuts: - callback = self.ksm.shortcuts[shortcut_key] - callback() + # Get the on_press handler + on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] - # Register a test shortcut (Ctrl+S) + # Register mock callback for double-tap Ctrl callback = MagicMock() + self.ksm.register_toggle_callback(callback) - # Create keys - ctrl_key = self.mock_keyboard.Key.ctrl - s_key = MagicMock() - s_key.char = "s" + # Set up initial state (recent trigger) + self.ksm.last_ctrl_press_time = ( + time.time() - 0.2 + ) # Recent press (within threshold) + self.ksm.last_trigger_time = ( + time.time() - 0.2 + ) # Recent trigger, within debounce - # Set up the shortcut - modifiers = {ctrl_key} - self.ksm.register_shortcut(modifiers, s_key, callback) + # Simulate second Ctrl press (should NOT trigger due to debounce) + on_press(self.mock_keyboard.Key.ctrl) - # Simulate pressing 's' - simplified_on_press(s_key) - - # Verify callback was triggered - callback.assert_called_once() + # Verify callback was NOT triggered + callback.assert_not_called() def test_key_release(self): """Test handling a key release.""" @@ -268,10 +245,7 @@ class TestKeyboardShortcuts(unittest.TestCase): on_release = self.mock_keyboard.Listener.call_args[1]["on_release"] # Add some keys - self.ksm.current_keys = { - self.mock_keyboard.Key.ctrl, - self.mock_keyboard.Key.alt, - } + self.ksm.current_keys = {self.mock_keyboard.Key.ctrl} # Simulate releasing Ctrl on_release(self.mock_keyboard.Key.ctrl) @@ -279,38 +253,6 @@ class TestKeyboardShortcuts(unittest.TestCase): # Verify Ctrl was removed self.assertNotIn(self.mock_keyboard.Key.ctrl, self.ksm.current_keys) - def test_shortcut_debounce(self): - """Test that shortcuts have debounce protection.""" - # Initialize and start to set up the listener - self.ksm.start() - - # Get the on_press handler - on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] - - # Register a test shortcut - callback = MagicMock() - - # Create keys - ctrl_key = self.mock_keyboard.Key.ctrl - s_key = MagicMock() - s_key.char = "s" - - # Set up the shortcut - modifiers = {ctrl_key} - self.ksm.register_shortcut(modifiers, s_key, callback) - - # Set up the current keys - self.ksm.current_keys = {ctrl_key} - - # Set last trigger time to now - self.ksm.last_trigger_time = time.time() - - # Simulate pressing 's' - on_press(s_key) - - # Callback should not be called due to debounce - callback.assert_not_called() - def test_error_handling(self): """Test error handling in key event handlers.""" # Initialize and start to set up the listener @@ -345,17 +287,3 @@ class TestKeyboardShortcuts(unittest.TestCase): # When keyboard is not available, active should remain False self.assertFalse(ksm.active) - - # Should still be able to register shortcuts, but they won't do anything - callback = MagicMock() - - # Create simulated keys - ctrl_key = "Key.ctrl" - s_key = MagicMock() - s_key.char = "s" - - # Register - ksm.register_shortcut({ctrl_key}, s_key, callback) - - # Verify it was added - self.assertEqual(len(ksm.shortcuts), 1)