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
This commit is contained in:
Jatin K Malik
2025-04-18 23:36:49 -05:00
committed by GitHub
parent ee0b41dea3
commit a33c0d1b4c
8 changed files with 133 additions and 225 deletions
+2 -1
View File
@@ -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
+4 -4
View File
@@ -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,
+22 -3
View File
@@ -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
+6 -7
View File
@@ -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
+7 -1
View File
@@ -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={
+1 -1
View File
@@ -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,
+32 -77
View File
@@ -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}")
+59 -131
View File
@@ -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)