Test Structure Reorganization and CI Improvements (#5)
* Add GitHub Actions CI/CD workflows and status badges * Fix CI workflow dependency installation * Fix CI workflow: remove pip caching and add explicit requirements.txt * Fix CI workflow: add system dependencies for PyGObject and pycairo * Refactor code for improved readability and consistency - Cleaned up whitespace and formatting in audio_feedback.py, config_manager.py, keyboard_shortcuts.py, tray_indicator.py, run_basic_tests.py, and test_basic.py. - Enhanced the structure of the code by aligning comments and ensuring consistent indentation. - Updated function definitions and calls to maintain a uniform style across the codebase. - Improved logging messages for better clarity during debugging. - Ensured all files end with a newline for better compatibility with various tools. * Refactor imports for consistency and clarity across multiple modules * Fix test imports by setting PYTHONPATH and adding conftest.py * Update CI workflow to use Python 3.12 and ensure proper import path in tests * Fix CI: Add GObject Introspection development dependencies * Update CI: Use virtual environment like local setup * Fix CI * Update README and setup.py: Change Python version to 3.8 and correct author email * Restructure tests for better organization and maintainability * Fix formatting * Update CI workflow to remove redundant test step * Fix CI tests by properly mocking VOSK model loading * Update CI and Tests * Simplify
This commit is contained in:
+21
-19
@@ -26,20 +26,22 @@ logger = logging.getLogger(__name__)
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description="Vocalinux")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Enable debug logging"
|
||||
"--model",
|
||||
type=str,
|
||||
default="small",
|
||||
help="Speech recognition model size (small, medium, large)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model", type=str, default="small",
|
||||
help="Speech recognition model size (small, medium, large)"
|
||||
"--engine",
|
||||
type=str,
|
||||
default="vosk",
|
||||
choices=["vosk", "whisper"],
|
||||
help="Speech recognition engine to use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--engine", type=str, default="vosk", choices=["vosk", "whisper"],
|
||||
help="Speech recognition engine to use"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wayland", action="store_true",
|
||||
help="Force Wayland compatibility mode"
|
||||
"--wayland", action="store_true", help="Force Wayland compatibility mode"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -47,36 +49,36 @@ def parse_arguments():
|
||||
def main():
|
||||
"""Main entry point for the application."""
|
||||
args = parse_arguments()
|
||||
|
||||
|
||||
# Configure debug logging if requested
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logger.debug("Debug logging enabled")
|
||||
|
||||
|
||||
# Initialize main components
|
||||
logger.info("Initializing Vocalinux...")
|
||||
|
||||
|
||||
# Initialize speech recognition engine
|
||||
speech_engine = recognition_manager.SpeechRecognitionManager(
|
||||
engine=args.engine,
|
||||
model_size=args.model,
|
||||
)
|
||||
|
||||
|
||||
# Initialize text injection system
|
||||
text_system = text_injector.TextInjector(wayland_mode=args.wayland)
|
||||
|
||||
|
||||
# Connect speech recognition to text injection
|
||||
speech_engine.register_text_callback(text_system.inject_text)
|
||||
|
||||
|
||||
# Initialize and start the system tray indicator
|
||||
indicator = tray_indicator.TrayIndicator(
|
||||
speech_engine=speech_engine,
|
||||
text_injector=text_system,
|
||||
)
|
||||
|
||||
|
||||
# Start the GTK main loop
|
||||
indicator.run()
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -15,11 +15,11 @@ logger = logging.getLogger(__name__)
|
||||
class CommandProcessor:
|
||||
"""
|
||||
Processes text commands in speech recognition results.
|
||||
|
||||
|
||||
This class handles special commands like "new line", "period",
|
||||
"delete that", etc.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the command processor."""
|
||||
# Map of command phrases to their actions
|
||||
@@ -27,7 +27,6 @@ class CommandProcessor:
|
||||
# Line commands
|
||||
"new line": "\n",
|
||||
"new paragraph": "\n\n",
|
||||
|
||||
# Punctuation
|
||||
"period": ".",
|
||||
"full stop": ".",
|
||||
@@ -40,7 +39,7 @@ class CommandProcessor:
|
||||
"dash": "-",
|
||||
"hyphen": "-",
|
||||
"underscore": "_",
|
||||
"quote": "\"",
|
||||
"quote": '"',
|
||||
"single quote": "'",
|
||||
"open parenthesis": "(",
|
||||
"close parenthesis": ")",
|
||||
@@ -49,7 +48,7 @@ class CommandProcessor:
|
||||
"open brace": "{",
|
||||
"close brace": "}",
|
||||
}
|
||||
|
||||
|
||||
# Special action commands that don't directly map to text
|
||||
self.action_commands = {
|
||||
"delete that": "delete_last",
|
||||
@@ -64,7 +63,7 @@ class CommandProcessor:
|
||||
"copy": "copy",
|
||||
"paste": "paste",
|
||||
}
|
||||
|
||||
|
||||
# Formatting commands that modify the next word
|
||||
self.format_commands = {
|
||||
"capitalize": "capitalize_next",
|
||||
@@ -73,34 +72,46 @@ class CommandProcessor:
|
||||
"lowercase": "lowercase_next",
|
||||
"no spaces": "no_spaces_next",
|
||||
}
|
||||
|
||||
|
||||
# Active format modifiers
|
||||
self.active_formats = set()
|
||||
|
||||
|
||||
# Compile regex patterns for faster matching
|
||||
self._compile_patterns()
|
||||
|
||||
|
||||
def _compile_patterns(self):
|
||||
"""Compile regex patterns for command matching."""
|
||||
# Create regex pattern for text commands
|
||||
text_cmd_pattern = r'\b(' + '|'.join(re.escape(cmd) for cmd in self.text_commands.keys()) + r')\b'
|
||||
text_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.text_commands.keys())
|
||||
+ r")\b"
|
||||
)
|
||||
self.text_cmd_regex = re.compile(text_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
# Create regex pattern for action commands
|
||||
action_cmd_pattern = r'\b(' + '|'.join(re.escape(cmd) for cmd in self.action_commands.keys()) + r')\b'
|
||||
action_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.action_commands.keys())
|
||||
+ r")\b"
|
||||
)
|
||||
self.action_cmd_regex = re.compile(action_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
# Create regex pattern for format commands
|
||||
format_cmd_pattern = r'\b(' + '|'.join(re.escape(cmd) for cmd in self.format_commands.keys()) + r')\b'
|
||||
format_cmd_pattern = (
|
||||
r"\b("
|
||||
+ "|".join(re.escape(cmd) for cmd in self.format_commands.keys())
|
||||
+ r")\b"
|
||||
)
|
||||
self.format_cmd_regex = re.compile(format_cmd_pattern, re.IGNORECASE)
|
||||
|
||||
|
||||
def process_text(self, text: str) -> Tuple[str, List[str]]:
|
||||
"""
|
||||
Process text commands in the recognized text.
|
||||
|
||||
|
||||
Args:
|
||||
text: The recognized text to process
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (processed_text, actions)
|
||||
- processed_text: The text with commands replaced
|
||||
@@ -108,38 +119,48 @@ class CommandProcessor:
|
||||
"""
|
||||
if not text:
|
||||
return "", []
|
||||
|
||||
|
||||
logger.debug(f"Processing commands in text: {text}")
|
||||
|
||||
|
||||
# Convert to lowercase for easier matching
|
||||
lower_text = text.lower()
|
||||
processed_text = text
|
||||
actions = []
|
||||
|
||||
|
||||
# Process action commands first (delete that, undo, etc.)
|
||||
action_matches = self.action_cmd_regex.findall(lower_text)
|
||||
for match in action_matches:
|
||||
action = self.action_commands[match]
|
||||
actions.append(action)
|
||||
# Remove the command from the text
|
||||
processed_text = re.sub(r'\b' + re.escape(match) + r'\b', '', processed_text, flags=re.IGNORECASE)
|
||||
|
||||
processed_text = re.sub(
|
||||
r"\b" + re.escape(match) + r"\b",
|
||||
"",
|
||||
processed_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Process format commands
|
||||
format_matches = self.format_cmd_regex.findall(lower_text)
|
||||
for match in format_matches:
|
||||
action = self.format_commands[match]
|
||||
self.active_formats.add(action)
|
||||
# Remove the command from the text
|
||||
processed_text = re.sub(r'\b' + re.escape(match) + r'\b', '', processed_text, flags=re.IGNORECASE)
|
||||
|
||||
processed_text = re.sub(
|
||||
r"\b" + re.escape(match) + r"\b",
|
||||
"",
|
||||
processed_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Apply active format modifiers to the next word
|
||||
if self.active_formats:
|
||||
# Find the next word
|
||||
word_match = re.search(r'\b(\w+)\b', processed_text)
|
||||
word_match = re.search(r"\b(\w+)\b", processed_text)
|
||||
if word_match:
|
||||
word = word_match.group(1)
|
||||
start, end = word_match.span(1)
|
||||
|
||||
|
||||
# Apply formatting
|
||||
formatted_word = word
|
||||
for format_type in self.active_formats:
|
||||
@@ -152,22 +173,24 @@ class CommandProcessor:
|
||||
elif format_type == "no_spaces_next":
|
||||
# This will be applied when combining with the next word
|
||||
pass
|
||||
|
||||
|
||||
# Replace the word in the text
|
||||
processed_text = processed_text[:start] + formatted_word + processed_text[end:]
|
||||
|
||||
processed_text = (
|
||||
processed_text[:start] + formatted_word + processed_text[end:]
|
||||
)
|
||||
|
||||
# Clear active formats
|
||||
self.active_formats.clear()
|
||||
|
||||
|
||||
# Finally, process text commands (period, comma, etc.)
|
||||
def replace_command(match):
|
||||
cmd = match.group(0).lower()
|
||||
replacement = self.text_commands.get(cmd, "")
|
||||
return replacement
|
||||
|
||||
|
||||
processed_text = self.text_cmd_regex.sub(replace_command, processed_text)
|
||||
|
||||
|
||||
# Clean up multiple spaces
|
||||
processed_text = re.sub(r'\s+', ' ', processed_text).strip()
|
||||
|
||||
return processed_text, actions
|
||||
processed_text = re.sub(r"\s+", " ", processed_text).strip()
|
||||
|
||||
return processed_text, actions
|
||||
|
||||
@@ -19,24 +19,36 @@ from .command_processor import CommandProcessor
|
||||
# Fix imports to use absolute imports instead of relative imports
|
||||
try:
|
||||
# Try absolute import first (when installed as a package)
|
||||
from src.ui.audio_feedback import play_start_sound, play_stop_sound, play_error_sound
|
||||
from src.ui.audio_feedback import (
|
||||
play_error_sound,
|
||||
play_start_sound,
|
||||
play_stop_sound,
|
||||
)
|
||||
except ImportError:
|
||||
try:
|
||||
# Fallback for development environment
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
from src.ui.audio_feedback import play_start_sound, play_stop_sound, play_error_sound
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
)
|
||||
from src.ui.audio_feedback import (
|
||||
play_error_sound,
|
||||
play_start_sound,
|
||||
play_stop_sound,
|
||||
)
|
||||
except ImportError:
|
||||
# Last resort fallback - define stub functions
|
||||
def play_start_sound():
|
||||
logging.warning("Audio feedback not available")
|
||||
|
||||
|
||||
def play_stop_sound():
|
||||
logging.warning("Audio feedback not available")
|
||||
|
||||
|
||||
def play_error_sound():
|
||||
logging.warning("Audio feedback not available")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define constants
|
||||
@@ -45,6 +57,7 @@ MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models")
|
||||
|
||||
class RecognitionState(Enum):
|
||||
"""Enum representing the state of the speech recognition system."""
|
||||
|
||||
IDLE = auto()
|
||||
LISTENING = auto()
|
||||
PROCESSING = auto()
|
||||
@@ -54,15 +67,15 @@ class RecognitionState(Enum):
|
||||
class SpeechRecognitionManager:
|
||||
"""
|
||||
Manager class for speech recognition engines.
|
||||
|
||||
|
||||
This class provides a unified interface for working with different
|
||||
speech recognition engines (VOSK and Whisper).
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, engine: str = "vosk", model_size: str = "small"):
|
||||
"""
|
||||
Initialize the speech recognition manager.
|
||||
|
||||
|
||||
Args:
|
||||
engine: The speech recognition engine to use ("vosk" or "whisper")
|
||||
model_size: The size of the model to use ("small", "medium", "large")
|
||||
@@ -77,20 +90,22 @@ class SpeechRecognitionManager:
|
||||
self.text_callbacks: List[Callable[[str], None]] = []
|
||||
self.state_callbacks: List[Callable[[RecognitionState], None]] = []
|
||||
self.action_callbacks: List[Callable[[str], None]] = []
|
||||
|
||||
|
||||
# Speech detection parameters
|
||||
self.vad_sensitivity = 3 # Voice Activity Detection sensitivity (1-5)
|
||||
self.silence_timeout = 2.0 # Seconds of silence before stopping recognition
|
||||
|
||||
|
||||
# Recording control flags
|
||||
self.should_record = False
|
||||
self.audio_buffer = []
|
||||
|
||||
|
||||
# Create models directory if it doesn't exist
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
|
||||
logger.info(f"Initializing speech recognition with {engine} engine and {model_size} model")
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Initializing speech recognition with {engine} engine and {model_size} model"
|
||||
)
|
||||
|
||||
# Initialize the selected speech recognition engine
|
||||
if engine == "vosk":
|
||||
self._init_vosk()
|
||||
@@ -98,39 +113,46 @@ class SpeechRecognitionManager:
|
||||
self._init_whisper()
|
||||
else:
|
||||
raise ValueError(f"Unsupported speech recognition engine: {engine}")
|
||||
|
||||
|
||||
def _init_vosk(self):
|
||||
"""Initialize the VOSK speech recognition engine."""
|
||||
try:
|
||||
from vosk import Model, KaldiRecognizer
|
||||
from vosk import KaldiRecognizer, Model
|
||||
|
||||
self.vosk_model_path = self._get_vosk_model_path()
|
||||
|
||||
|
||||
if not os.path.exists(self.vosk_model_path):
|
||||
logger.info(f"VOSK model not found at {self.vosk_model_path}. Downloading...")
|
||||
logger.info(
|
||||
f"VOSK model not found at {self.vosk_model_path}. Downloading..."
|
||||
)
|
||||
self._download_vosk_model()
|
||||
|
||||
|
||||
logger.info(f"Loading VOSK model from {self.vosk_model_path}")
|
||||
self.model = Model(self.vosk_model_path)
|
||||
self.recognizer = KaldiRecognizer(self.model, 16000)
|
||||
|
||||
|
||||
except ImportError:
|
||||
logger.error("Failed to import VOSK. Please install it with 'pip install vosk'")
|
||||
logger.error(
|
||||
"Failed to import VOSK. Please install it with 'pip install vosk'"
|
||||
)
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
|
||||
def _init_whisper(self):
|
||||
"""Initialize the Whisper speech recognition engine."""
|
||||
try:
|
||||
import whisper
|
||||
|
||||
|
||||
logger.info(f"Loading Whisper {self.model_size} model")
|
||||
self.model = whisper.load_model(self.model_size)
|
||||
|
||||
|
||||
except ImportError:
|
||||
logger.error("Failed to import Whisper. Please install it with 'pip install whisper torch'")
|
||||
logger.error(
|
||||
"Failed to import Whisper. Please install it with 'pip install whisper torch'"
|
||||
)
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
|
||||
def _get_vosk_model_path(self) -> str:
|
||||
"""Get the path to the VOSK model based on the selected size."""
|
||||
model_map = {
|
||||
@@ -138,176 +160,182 @@ class SpeechRecognitionManager:
|
||||
"medium": "vosk-model-en-us-0.22",
|
||||
"large": "vosk-model-en-us-0.42",
|
||||
}
|
||||
|
||||
|
||||
model_name = model_map.get(self.model_size, model_map["small"])
|
||||
return os.path.join(MODELS_DIR, model_name)
|
||||
|
||||
|
||||
def _download_vosk_model(self):
|
||||
"""Download the VOSK model if it doesn't exist."""
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
import tqdm
|
||||
import zipfile
|
||||
|
||||
|
||||
model_urls = {
|
||||
"small": "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip",
|
||||
"medium": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22.zip",
|
||||
"large": "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42.zip",
|
||||
}
|
||||
|
||||
|
||||
url = model_urls.get(self.model_size)
|
||||
if not url:
|
||||
raise ValueError(f"Unknown model size: {self.model_size}")
|
||||
|
||||
|
||||
model_name = os.path.basename(url).replace(".zip", "")
|
||||
model_path = os.path.join(MODELS_DIR, model_name)
|
||||
zip_path = os.path.join(MODELS_DIR, os.path.basename(url))
|
||||
|
||||
|
||||
# Download the model
|
||||
logger.info(f"Downloading VOSK model from {url}")
|
||||
response = requests.get(url, stream=True)
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
for data in tqdm.tqdm(response.iter_content(chunk_size=1024), total=total_size//1024, unit="KB"):
|
||||
for data in tqdm.tqdm(
|
||||
response.iter_content(chunk_size=1024),
|
||||
total=total_size // 1024,
|
||||
unit="KB",
|
||||
):
|
||||
f.write(data)
|
||||
|
||||
|
||||
# Extract the model
|
||||
logger.info(f"Extracting VOSK model to {model_path}")
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(MODELS_DIR)
|
||||
|
||||
|
||||
# Remove the zip file
|
||||
os.remove(zip_path)
|
||||
logger.info("VOSK model downloaded and extracted successfully")
|
||||
|
||||
|
||||
def register_text_callback(self, callback: Callable[[str], None]):
|
||||
"""
|
||||
Register a callback function that will be called when text is recognized.
|
||||
|
||||
|
||||
Args:
|
||||
callback: A function that takes a string argument (the recognized text)
|
||||
"""
|
||||
self.text_callbacks.append(callback)
|
||||
|
||||
|
||||
def register_state_callback(self, callback: Callable[[RecognitionState], None]):
|
||||
"""
|
||||
Register a callback function that will be called when the recognition state changes.
|
||||
|
||||
|
||||
Args:
|
||||
callback: A function that takes a RecognitionState argument
|
||||
"""
|
||||
self.state_callbacks.append(callback)
|
||||
|
||||
|
||||
def register_action_callback(self, callback: Callable[[str], None]):
|
||||
"""
|
||||
Register a callback function that will be called when a special action is triggered.
|
||||
|
||||
|
||||
Args:
|
||||
callback: A function that takes a string argument (the action)
|
||||
"""
|
||||
self.action_callbacks.append(callback)
|
||||
|
||||
|
||||
def _update_state(self, new_state: RecognitionState):
|
||||
"""
|
||||
Update the recognition state and notify callbacks.
|
||||
|
||||
|
||||
Args:
|
||||
new_state: The new recognition state
|
||||
"""
|
||||
self.state = new_state
|
||||
for callback in self.state_callbacks:
|
||||
callback(new_state)
|
||||
|
||||
|
||||
def start_recognition(self):
|
||||
"""Start the speech recognition process."""
|
||||
if self.state != RecognitionState.IDLE:
|
||||
logger.warning(f"Cannot start recognition in current state: {self.state}")
|
||||
return
|
||||
|
||||
|
||||
logger.info("Starting speech recognition")
|
||||
self._update_state(RecognitionState.LISTENING)
|
||||
|
||||
|
||||
# Play the start sound
|
||||
play_start_sound()
|
||||
|
||||
|
||||
# Set recording flag
|
||||
self.should_record = True
|
||||
self.audio_buffer = []
|
||||
|
||||
|
||||
# Start the audio recording thread
|
||||
self.audio_thread = threading.Thread(target=self._record_audio)
|
||||
self.audio_thread.daemon = True
|
||||
self.audio_thread.start()
|
||||
|
||||
|
||||
# Start the recognition thread
|
||||
self.recognition_thread = threading.Thread(target=self._perform_recognition)
|
||||
self.recognition_thread.daemon = True
|
||||
self.recognition_thread.start()
|
||||
|
||||
|
||||
def stop_recognition(self):
|
||||
"""Stop the speech recognition process."""
|
||||
if self.state == RecognitionState.IDLE:
|
||||
return
|
||||
|
||||
|
||||
logger.info("Stopping speech recognition")
|
||||
|
||||
|
||||
# Play the stop sound
|
||||
play_stop_sound()
|
||||
|
||||
|
||||
# Clear recording flag
|
||||
self.should_record = False
|
||||
|
||||
|
||||
# Wait for threads to finish
|
||||
if self.audio_thread and self.audio_thread.is_alive():
|
||||
self.audio_thread.join(timeout=1.0)
|
||||
|
||||
|
||||
if self.recognition_thread and self.recognition_thread.is_alive():
|
||||
self.recognition_thread.join(timeout=1.0)
|
||||
|
||||
|
||||
self._update_state(RecognitionState.IDLE)
|
||||
|
||||
|
||||
def _record_audio(self):
|
||||
"""Record audio from the microphone."""
|
||||
try:
|
||||
import pyaudio
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
|
||||
import pyaudio
|
||||
|
||||
# PyAudio configuration
|
||||
CHUNK = 1024
|
||||
FORMAT = pyaudio.paInt16
|
||||
CHANNELS = 1
|
||||
RATE = 16000
|
||||
|
||||
|
||||
# Initialize PyAudio
|
||||
audio = pyaudio.PyAudio()
|
||||
|
||||
|
||||
# Open microphone stream
|
||||
stream = audio.open(
|
||||
format=FORMAT,
|
||||
channels=CHANNELS,
|
||||
rate=RATE,
|
||||
input=True,
|
||||
frames_per_buffer=CHUNK
|
||||
frames_per_buffer=CHUNK,
|
||||
)
|
||||
|
||||
|
||||
logger.info("Audio recording started")
|
||||
|
||||
|
||||
# Record audio while should_record is True
|
||||
silence_counter = 0
|
||||
while self.should_record:
|
||||
try:
|
||||
data = stream.read(CHUNK, exception_on_overflow=False)
|
||||
self.audio_buffer.append(data)
|
||||
|
||||
|
||||
# Simple Voice Activity Detection (VAD)
|
||||
# TODO: Implement proper VAD using webrtcvad or similar
|
||||
audio_data = np.frombuffer(data, dtype=np.int16)
|
||||
volume = np.abs(audio_data).mean()
|
||||
|
||||
|
||||
# Threshold based on sensitivity (1-5)
|
||||
threshold = 500 / self.vad_sensitivity
|
||||
|
||||
|
||||
if volume < threshold: # Silence
|
||||
silence_counter += CHUNK / RATE # Convert chunks to seconds
|
||||
if silence_counter > self.silence_timeout:
|
||||
@@ -324,85 +352,85 @@ class SpeechRecognitionManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading audio data: {e}")
|
||||
break
|
||||
|
||||
|
||||
# Clean up
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
audio.terminate()
|
||||
logger.info("Audio recording stopped")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in audio recording: {e}")
|
||||
play_error_sound()
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
|
||||
|
||||
def _process_final_buffer(self):
|
||||
"""Process the final audio buffer after silence is detected."""
|
||||
if not self.audio_buffer:
|
||||
return
|
||||
|
||||
|
||||
if self.engine == "vosk":
|
||||
for data in self.audio_buffer:
|
||||
self.recognizer.AcceptWaveform(data)
|
||||
|
||||
|
||||
result = json.loads(self.recognizer.FinalResult())
|
||||
text = result.get("text", "")
|
||||
|
||||
|
||||
elif self.engine == "whisper":
|
||||
# Save buffer to a temporary file
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
with wave.open(temp_path, 'wb') as wf:
|
||||
|
||||
with wave.open(temp_path, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2) # 16-bit
|
||||
wf.setframerate(16000)
|
||||
wf.writeframes(b''.join(self.audio_buffer))
|
||||
|
||||
wf.writeframes(b"".join(self.audio_buffer))
|
||||
|
||||
# Process with Whisper
|
||||
result = self.model.transcribe(temp_path)
|
||||
text = result.get("text", "")
|
||||
|
||||
|
||||
# Remove temporary file
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown engine: {self.engine}")
|
||||
return
|
||||
|
||||
|
||||
# Process commands
|
||||
if text:
|
||||
processed_text, actions = self.command_processor.process_text(text)
|
||||
|
||||
|
||||
# Call text callbacks with processed text
|
||||
if processed_text:
|
||||
for callback in self.text_callbacks:
|
||||
callback(processed_text)
|
||||
|
||||
|
||||
# Call action callbacks for each action
|
||||
for action in actions:
|
||||
for callback in self.action_callbacks:
|
||||
callback(action)
|
||||
|
||||
|
||||
def _perform_recognition(self):
|
||||
"""Perform speech recognition in real-time."""
|
||||
while self.should_record:
|
||||
# The real work is done in _record_audio and _process_final_buffer
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def configure(self, vad_sensitivity: int = None, silence_timeout: float = None):
|
||||
"""
|
||||
Configure recognition parameters.
|
||||
|
||||
|
||||
Args:
|
||||
vad_sensitivity: Voice Activity Detection sensitivity (1-5)
|
||||
silence_timeout: Seconds of silence before stopping recognition
|
||||
"""
|
||||
if vad_sensitivity is not None:
|
||||
self.vad_sensitivity = max(1, min(5, vad_sensitivity))
|
||||
|
||||
|
||||
if silence_timeout is not None:
|
||||
self.silence_timeout = max(0.5, min(5.0, silence_timeout))
|
||||
self.silence_timeout = max(0.5, min(5.0, silence_timeout))
|
||||
|
||||
@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class DesktopEnvironment(Enum):
|
||||
"""Enum representing the desktop environment."""
|
||||
|
||||
X11 = "x11"
|
||||
WAYLAND = "wayland"
|
||||
WAYLAND_XDOTOOL = "wayland-xdotool" # Wayland with XWayland fallback
|
||||
@@ -27,46 +28,59 @@ class DesktopEnvironment(Enum):
|
||||
class TextInjector:
|
||||
"""
|
||||
Class for injecting text into the active application.
|
||||
|
||||
|
||||
This class handles the injection of text into the currently focused
|
||||
application window, supporting both X11 and Wayland environments.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, wayland_mode: bool = False):
|
||||
"""
|
||||
Initialize the text injector.
|
||||
|
||||
|
||||
Args:
|
||||
wayland_mode: Force Wayland compatibility mode
|
||||
"""
|
||||
self.environment = self._detect_environment()
|
||||
|
||||
|
||||
# Force Wayland mode if requested
|
||||
if wayland_mode and self.environment == DesktopEnvironment.X11:
|
||||
logger.info("Forcing Wayland compatibility mode")
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
|
||||
|
||||
logger.info(f"Using text injection for {self.environment.value} environment")
|
||||
|
||||
|
||||
# Check for required tools
|
||||
self._check_dependencies()
|
||||
|
||||
|
||||
# Test if wtype actually works in this environment
|
||||
if self.environment == DesktopEnvironment.WAYLAND and hasattr(self, 'wayland_tool') and self.wayland_tool == "wtype":
|
||||
if (
|
||||
self.environment == DesktopEnvironment.WAYLAND
|
||||
and hasattr(self, "wayland_tool")
|
||||
and self.wayland_tool == "wtype"
|
||||
):
|
||||
try:
|
||||
# Try a test with wtype
|
||||
result = subprocess.run(["wtype", "test"], stderr=subprocess.PIPE, text=True, check=False)
|
||||
result = subprocess.run(
|
||||
["wtype", "test"], stderr=subprocess.PIPE, text=True, check=False
|
||||
)
|
||||
error_output = result.stderr.lower()
|
||||
if "compositor does not support" in error_output or result.returncode != 0:
|
||||
logger.warning(f"Wayland compositor does not support virtual keyboard protocol: {error_output}")
|
||||
if (
|
||||
"compositor does not support" in error_output
|
||||
or result.returncode != 0
|
||||
):
|
||||
logger.warning(
|
||||
f"Wayland compositor does not support virtual keyboard protocol: {error_output}"
|
||||
)
|
||||
if shutil.which("xdotool"):
|
||||
logger.info("Automatically switching to XWayland fallback with xdotool")
|
||||
logger.info(
|
||||
"Automatically switching to XWayland fallback with xdotool"
|
||||
)
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
else:
|
||||
logger.error("No fallback text injection method available")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error testing wtype: {e}, will try to use it anyway")
|
||||
|
||||
|
||||
# Verify XWayland fallback works - perform a test injection
|
||||
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
|
||||
logger.info("Testing XWayland text injection fallback")
|
||||
@@ -77,11 +91,11 @@ class TextInjector:
|
||||
self._test_xdotool_fallback()
|
||||
except Exception as e:
|
||||
logger.error(f"XWayland fallback test failed: {e}")
|
||||
|
||||
|
||||
def _detect_environment(self) -> DesktopEnvironment:
|
||||
"""
|
||||
Detect the current desktop environment (X11 or Wayland).
|
||||
|
||||
|
||||
Returns:
|
||||
The detected desktop environment
|
||||
"""
|
||||
@@ -97,22 +111,26 @@ class TextInjector:
|
||||
elif "DISPLAY" in os.environ:
|
||||
return DesktopEnvironment.X11
|
||||
else:
|
||||
logger.warning("Could not detect desktop environment, defaulting to X11")
|
||||
logger.warning(
|
||||
"Could not detect desktop environment, defaulting to X11"
|
||||
)
|
||||
return DesktopEnvironment.X11
|
||||
|
||||
|
||||
def _check_dependencies(self):
|
||||
"""Check for the required tools for text injection."""
|
||||
if self.environment == DesktopEnvironment.X11:
|
||||
# Check for xdotool
|
||||
if not shutil.which("xdotool"):
|
||||
logger.error("xdotool not found. Please install it with: sudo apt install xdotool")
|
||||
logger.error(
|
||||
"xdotool not found. Please install it with: sudo apt install xdotool"
|
||||
)
|
||||
raise RuntimeError("Missing required dependency: xdotool")
|
||||
else:
|
||||
# Check for wtype or ydotool for Wayland
|
||||
wtype_available = shutil.which("wtype") is not None
|
||||
ydotool_available = shutil.which("ydotool") is not None
|
||||
xdotool_available = shutil.which("xdotool") is not None
|
||||
|
||||
|
||||
if wtype_available:
|
||||
self.wayland_tool = "wtype"
|
||||
logger.info(f"Using {self.wayland_tool} for Wayland text injection")
|
||||
@@ -122,35 +140,39 @@ class TextInjector:
|
||||
elif xdotool_available:
|
||||
# Fallback to xdotool with XWayland
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
logger.info("No native Wayland tools found. Using xdotool with XWayland as fallback")
|
||||
logger.info(
|
||||
"No native Wayland tools found. Using xdotool with XWayland as fallback"
|
||||
)
|
||||
else:
|
||||
logger.error("No text injection tools found. Please install one of:\n"
|
||||
"- wtype: sudo apt install wtype\n"
|
||||
"- ydotool: sudo apt install ydotool\n"
|
||||
"- xdotool: sudo apt install xdotool")
|
||||
logger.error(
|
||||
"No text injection tools found. Please install one of:\n"
|
||||
"- wtype: sudo apt install wtype\n"
|
||||
"- ydotool: sudo apt install ydotool\n"
|
||||
"- xdotool: sudo apt install xdotool"
|
||||
)
|
||||
raise RuntimeError("Missing required dependencies for text injection")
|
||||
|
||||
|
||||
def _test_xdotool_fallback(self):
|
||||
"""Test if xdotool is working correctly with XWayland."""
|
||||
try:
|
||||
# Get the DISPLAY environment variable for XWayland
|
||||
xwayland_display = os.environ.get("DISPLAY", ":0")
|
||||
logger.debug(f"Using DISPLAY={xwayland_display} for XWayland")
|
||||
|
||||
|
||||
# Try using xdotool with explicit DISPLAY setting
|
||||
test_env = os.environ.copy()
|
||||
test_env["DISPLAY"] = xwayland_display
|
||||
|
||||
|
||||
# Check if we can get active window (less intrusive test)
|
||||
window_id = subprocess.run(
|
||||
["xdotool", "getwindowfocus"],
|
||||
env=test_env,
|
||||
stdout=subprocess.PIPE,
|
||||
["xdotool", "getwindowfocus"],
|
||||
env=test_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
if window_id.returncode != 0 or "failed" in window_id.stderr.lower():
|
||||
logger.warning(f"XWayland detection test failed: {window_id.stderr}")
|
||||
# Try to force XWayland environment more explicitly
|
||||
@@ -159,32 +181,39 @@ class TextInjector:
|
||||
logger.debug("XWayland test successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test XWayland fallback: {e}")
|
||||
|
||||
|
||||
def inject_text(self, text: str):
|
||||
"""
|
||||
Inject text into the currently focused application.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text to inject
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
|
||||
logger.debug(f"Injecting text: {text}")
|
||||
|
||||
|
||||
# Escape special characters for shell
|
||||
escaped_text = self._escape_text(text)
|
||||
|
||||
|
||||
try:
|
||||
if self.environment == DesktopEnvironment.X11 or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
|
||||
if (
|
||||
self.environment == DesktopEnvironment.X11
|
||||
or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
):
|
||||
self._inject_with_xdotool(escaped_text)
|
||||
else:
|
||||
try:
|
||||
self._inject_with_wayland_tool(escaped_text)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f"Wayland tool failed: {e}. Falling back to xdotool")
|
||||
if "compositor does not support" in str(e).lower() and shutil.which("xdotool"):
|
||||
logger.info("Automatically switching to XWayland fallback permanently")
|
||||
if "compositor does not support" in str(e).lower() and shutil.which(
|
||||
"xdotool"
|
||||
):
|
||||
logger.info(
|
||||
"Automatically switching to XWayland fallback permanently"
|
||||
)
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
self._inject_with_xdotool(escaped_text)
|
||||
else:
|
||||
@@ -192,43 +221,44 @@ class TextInjector:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to inject text: {e}")
|
||||
from ..ui.audio_feedback import play_error_sound
|
||||
|
||||
play_error_sound() # Play error sound when text injection fails
|
||||
|
||||
|
||||
def _escape_text(self, text: str) -> str:
|
||||
"""
|
||||
Escape special characters in the text for shell commands.
|
||||
|
||||
|
||||
Args:
|
||||
text: The original text
|
||||
|
||||
|
||||
Returns:
|
||||
The escaped text
|
||||
"""
|
||||
# Replace common special characters
|
||||
replacements = {
|
||||
"'": r"\'",
|
||||
'"': r'\"',
|
||||
'`': r'\`',
|
||||
'\\': r'\\',
|
||||
'$': r'\$',
|
||||
'"': r"\"",
|
||||
"`": r"\`",
|
||||
"\\": r"\\",
|
||||
"$": r"\$",
|
||||
}
|
||||
|
||||
|
||||
result = text
|
||||
for char, replacement in replacements.items():
|
||||
result = result.replace(char, replacement)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _inject_with_xdotool(self, text: str):
|
||||
"""
|
||||
Inject text using xdotool for X11 environments.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text to inject (already escaped)
|
||||
"""
|
||||
# Create environment with explicit X11 settings for Wayland compatibility
|
||||
env = os.environ.copy()
|
||||
|
||||
|
||||
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
|
||||
# Force X11 backend for XWayland
|
||||
env["GDK_BACKEND"] = "x11"
|
||||
@@ -236,39 +266,39 @@ class TextInjector:
|
||||
# Ensure DISPLAY is set correctly for XWayland
|
||||
if "DISPLAY" not in env or not env["DISPLAY"]:
|
||||
env["DISPLAY"] = ":0"
|
||||
|
||||
|
||||
logger.debug(f"Using XWayland with DISPLAY={env['DISPLAY']}")
|
||||
|
||||
|
||||
# Add a small delay to ensure text is injected properly
|
||||
time.sleep(0.3) # Increased delay for better reliability
|
||||
|
||||
|
||||
# Try to ensure the window has focus using more robust approach
|
||||
try:
|
||||
# Get current active window
|
||||
active_window = subprocess.run(
|
||||
["xdotool", "getactivewindow"],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
["xdotool", "getactivewindow"],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
if active_window.returncode == 0 and active_window.stdout.strip():
|
||||
window_id = active_window.stdout.strip()
|
||||
# Focus explicitly on that window
|
||||
subprocess.run(
|
||||
["xdotool", "windowactivate", "--sync", window_id],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
["xdotool", "windowactivate", "--sync", window_id],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
# Wait a moment for the focus to take effect
|
||||
time.sleep(0.2)
|
||||
except Exception as e:
|
||||
logger.debug(f"Window focus command failed: {e}")
|
||||
|
||||
|
||||
# Inject text using xdotool
|
||||
try:
|
||||
max_retries = 2
|
||||
@@ -277,44 +307,50 @@ class TextInjector:
|
||||
# Inject in smaller chunks to avoid issues with very long text
|
||||
chunk_size = 20 # Reduced chunk size for better reliability
|
||||
for i in range(0, len(text), chunk_size):
|
||||
chunk = text[i:i+chunk_size]
|
||||
|
||||
chunk = text[i : i + chunk_size]
|
||||
|
||||
# First try with clearmodifiers
|
||||
cmd = ["xdotool", "type", "--clearmodifiers", chunk]
|
||||
result = subprocess.run(cmd, env=env, check=True, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, env=env, check=True, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
|
||||
# Add a larger delay between chunks
|
||||
if i + chunk_size < len(text):
|
||||
time.sleep(0.1)
|
||||
|
||||
logger.info(f"Text injected using xdotool: '{text[:20]}...' ({len(text)} chars)")
|
||||
|
||||
logger.info(
|
||||
f"Text injected using xdotool: '{text[:20]}...' ({len(text)} chars)"
|
||||
)
|
||||
break # Successfully injected
|
||||
except subprocess.CalledProcessError as chunk_error:
|
||||
if retry < max_retries:
|
||||
logger.warning(f"Retrying text injection (attempt {retry+1}/{max_retries}): {chunk_error.stderr}")
|
||||
logger.warning(
|
||||
f"Retrying text injection (attempt {retry+1}/{max_retries}): {chunk_error.stderr}"
|
||||
)
|
||||
time.sleep(0.5) # Wait before retry
|
||||
else:
|
||||
raise # Re-raise on final attempt
|
||||
|
||||
|
||||
# Try to reset any stuck modifiers
|
||||
try:
|
||||
subprocess.run(
|
||||
["xdotool", "key", "--clearmodifiers", "Escape"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
["xdotool", "key", "--clearmodifiers", "Escape"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
pass # Ignore any errors from this command
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"xdotool error: {e.stderr}")
|
||||
raise
|
||||
|
||||
|
||||
def _inject_with_wayland_tool(self, text: str):
|
||||
"""
|
||||
Inject text using a Wayland-compatible tool (wtype or ydotool).
|
||||
|
||||
|
||||
Args:
|
||||
text: The text to inject (already escaped)
|
||||
"""
|
||||
@@ -322,6 +358,8 @@ class TextInjector:
|
||||
cmd = ["wtype", text]
|
||||
else: # ydotool
|
||||
cmd = ["ydotool", "type", text]
|
||||
|
||||
|
||||
result = subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True)
|
||||
logger.info(f"Text injected using {self.wayland_tool}: '{text[:20]}...' ({len(text)} chars)")
|
||||
logger.info(
|
||||
f"Text injected using {self.wayland_tool}: '{text[:20]}...' ({len(text)} chars)"
|
||||
)
|
||||
|
||||
+31
-23
@@ -6,8 +6,8 @@ This module provides audio feedback for various recognition states.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,22 +27,22 @@ ERROR_SOUND = os.path.join(SOUNDS_DIR, "error.wav")
|
||||
def _get_audio_player():
|
||||
"""
|
||||
Determine the best available audio player on the system.
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (player_command, format_supported)
|
||||
"""
|
||||
# Check for PulseAudio paplay (preferred)
|
||||
if shutil.which("paplay"):
|
||||
return "paplay", ["wav"]
|
||||
|
||||
|
||||
# Check for ALSA aplay
|
||||
if shutil.which("aplay"):
|
||||
return "aplay", ["wav"]
|
||||
|
||||
|
||||
# Check for play (from SoX)
|
||||
if shutil.which("play"):
|
||||
return "play", ["wav"]
|
||||
|
||||
|
||||
# Check for mplayer
|
||||
if shutil.which("mplayer"):
|
||||
return "mplayer", ["wav"]
|
||||
@@ -55,40 +55,48 @@ def _get_audio_player():
|
||||
def _play_sound_file(sound_path):
|
||||
"""
|
||||
Play a sound file using the best available player.
|
||||
|
||||
|
||||
Args:
|
||||
sound_path: Path to the sound file
|
||||
"""
|
||||
if not os.path.exists(sound_path):
|
||||
logger.warning(f"Sound file not found: {sound_path}")
|
||||
return False
|
||||
|
||||
|
||||
player, formats = _get_audio_player()
|
||||
if not player:
|
||||
return False
|
||||
|
||||
file_ext = os.path.splitext(sound_path)[1].lower().lstrip('.')
|
||||
|
||||
file_ext = os.path.splitext(sound_path)[1].lower().lstrip(".")
|
||||
if file_ext not in formats:
|
||||
logger.warning(f"Format {file_ext} not supported by {player}")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
if player == "paplay":
|
||||
subprocess.Popen([player, sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
[player, sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif player == "aplay":
|
||||
subprocess.Popen([player, "-q", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
[player, "-q", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif player == "mplayer":
|
||||
subprocess.Popen([player, "-really-quiet", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
[player, "-really-quiet", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif player == "play":
|
||||
subprocess.Popen([player, "-q", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
[player, "-q", sound_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play sound {sound_path}: {e}")
|
||||
@@ -107,4 +115,4 @@ def play_stop_sound():
|
||||
|
||||
def play_error_sound():
|
||||
"""Play the sound for error notifications."""
|
||||
_play_sound_file(ERROR_SOUND)
|
||||
_play_sound_file(ERROR_SOUND)
|
||||
|
||||
+31
-27
@@ -19,11 +19,11 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG = {
|
||||
"recognition": {
|
||||
"engine": "vosk", # "vosk" or "whisper"
|
||||
"model_size": "small", # "small", "medium", or "large"
|
||||
"engine": "vosk", # "vosk" or "whisper"
|
||||
"model_size": "small", # "small", "medium", or "large"
|
||||
"auto_punctuate": True,
|
||||
"vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5)
|
||||
"timeout": 2.0, # Seconds of silence before stopping recognition
|
||||
"timeout": 2.0, # Seconds of silence before stopping recognition
|
||||
},
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "alt+shift+v",
|
||||
@@ -35,71 +35,71 @@ DEFAULT_CONFIG = {
|
||||
"advanced": {
|
||||
"debug_logging": False,
|
||||
"wayland_mode": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Manager for user configuration settings.
|
||||
|
||||
|
||||
This class provides methods for loading, saving, and accessing user
|
||||
preferences for the application.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the configuration manager."""
|
||||
self.config = DEFAULT_CONFIG.copy()
|
||||
self._ensure_config_dir()
|
||||
self.load_config()
|
||||
|
||||
|
||||
def _ensure_config_dir(self):
|
||||
"""Ensure the configuration directory exists."""
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def load_config(self):
|
||||
"""
|
||||
Load configuration from the config file.
|
||||
|
||||
|
||||
If the config file doesn't exist, the default configuration is used.
|
||||
"""
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
logger.info(f"Config file not found at {CONFIG_FILE}. Using defaults.")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
user_config = json.load(f)
|
||||
|
||||
|
||||
# Update the default config with user settings
|
||||
self._update_dict_recursive(self.config, user_config)
|
||||
logger.info(f"Loaded configuration from {CONFIG_FILE}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config: {e}")
|
||||
|
||||
|
||||
def save_config(self):
|
||||
"""Save the current configuration to the config file."""
|
||||
try:
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(self.config, f, indent=4)
|
||||
|
||||
|
||||
logger.info(f"Saved configuration to {CONFIG_FILE}")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get(self, section: str, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a configuration value.
|
||||
|
||||
|
||||
Args:
|
||||
section: The configuration section (e.g., "recognition", "shortcuts")
|
||||
key: The configuration key within the section
|
||||
default: The default value to return if the key doesn't exist
|
||||
|
||||
|
||||
Returns:
|
||||
The configuration value
|
||||
"""
|
||||
@@ -107,40 +107,44 @@ class ConfigManager:
|
||||
return self.config[section][key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
|
||||
def set(self, section: str, key: str, value: Any) -> bool:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
|
||||
Args:
|
||||
section: The configuration section (e.g., "recognition", "shortcuts")
|
||||
key: The configuration key within the section
|
||||
value: The value to set
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if section not in self.config:
|
||||
self.config[section] = {}
|
||||
|
||||
|
||||
self.config[section][key] = value
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set config value: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _update_dict_recursive(self, target: Dict, source: Dict):
|
||||
"""
|
||||
Update a dictionary recursively.
|
||||
|
||||
|
||||
Args:
|
||||
target: The target dictionary to update
|
||||
source: The source dictionary with updates
|
||||
"""
|
||||
for key, value in source.items():
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
if (
|
||||
key in target
|
||||
and isinstance(target[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
self._update_dict_recursive(target[key], value)
|
||||
else:
|
||||
target[key] = value
|
||||
target[key] = value
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Callable, Dict, Optional, Tuple
|
||||
# Try to import X11 keyboard libraries first
|
||||
try:
|
||||
from pynput import keyboard
|
||||
|
||||
KEYBOARD_AVAILABLE = True
|
||||
except ImportError:
|
||||
KEYBOARD_AVAILABLE = False
|
||||
@@ -23,51 +24,52 @@ logger = logging.getLogger(__name__)
|
||||
class KeyboardShortcutManager:
|
||||
"""
|
||||
Manages global keyboard shortcuts for the application.
|
||||
|
||||
|
||||
This class allows registering global keyboard shortcuts that work
|
||||
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
|
||||
|
||||
|
||||
if not KEYBOARD_AVAILABLE:
|
||||
logger.error("Keyboard shortcut libraries not available. Shortcuts will not work.")
|
||||
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')
|
||||
keyboard.KeyCode.from_char("v"),
|
||||
)
|
||||
|
||||
|
||||
def start(self):
|
||||
"""Start listening for keyboard shortcuts."""
|
||||
if not KEYBOARD_AVAILABLE:
|
||||
return
|
||||
|
||||
|
||||
if self.active:
|
||||
return
|
||||
|
||||
|
||||
logger.info("Starting keyboard shortcut listener")
|
||||
self.active = True
|
||||
|
||||
|
||||
# Track currently pressed modifier keys
|
||||
self.current_keys = set()
|
||||
|
||||
|
||||
try:
|
||||
# Start keyboard listener in a separate thread
|
||||
self.listener = keyboard.Listener(
|
||||
on_press=self._on_press,
|
||||
on_release=self._on_release
|
||||
on_press=self._on_press, on_release=self._on_release
|
||||
)
|
||||
self.listener.daemon = True
|
||||
self.listener.start()
|
||||
|
||||
|
||||
# Verify the listener started successfully
|
||||
if not self.listener.is_alive():
|
||||
logger.error("Failed to start keyboard listener")
|
||||
@@ -77,15 +79,15 @@ class KeyboardShortcutManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting keyboard listener: {e}")
|
||||
self.active = False
|
||||
|
||||
|
||||
def stop(self):
|
||||
"""Stop listening for keyboard shortcuts."""
|
||||
if not self.active or not self.listener:
|
||||
return
|
||||
|
||||
|
||||
logger.info("Stopping keyboard shortcut listener")
|
||||
self.active = False
|
||||
|
||||
|
||||
if self.listener:
|
||||
try:
|
||||
self.listener.stop()
|
||||
@@ -94,11 +96,11 @@ class KeyboardShortcutManager:
|
||||
logger.error(f"Error stopping keyboard listener: {e}")
|
||||
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'))
|
||||
@@ -106,79 +108,91 @@ class KeyboardShortcutManager:
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
Args:
|
||||
callback: Function to call when the toggle shortcut 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:
|
||||
if hasattr(key, "char") and key.char:
|
||||
return key.char.upper()
|
||||
elif hasattr(key, 'name'):
|
||||
elif hasattr(key, "name"):
|
||||
return key.name
|
||||
else:
|
||||
return str(key)
|
||||
|
||||
|
||||
def _on_press(self, key):
|
||||
"""
|
||||
Handle key press events.
|
||||
|
||||
|
||||
Args:
|
||||
key: The pressed key
|
||||
"""
|
||||
try:
|
||||
# Add to currently pressed keys (only for modifier keys)
|
||||
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
|
||||
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'):
|
||||
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}
|
||||
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}")
|
||||
|
||||
|
||||
def _on_release(self, key):
|
||||
"""
|
||||
Handle key release events.
|
||||
|
||||
|
||||
Args:
|
||||
key: The released key
|
||||
"""
|
||||
@@ -189,7 +203,7 @@ class KeyboardShortcutManager:
|
||||
self.current_keys.discard(normalized_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in keyboard release handling: {e}")
|
||||
|
||||
|
||||
def _normalize_modifier_key(self, key):
|
||||
"""Normalize left/right variants of modifier keys to their base form."""
|
||||
# Map left/right variants to their base key
|
||||
@@ -201,7 +215,7 @@ class KeyboardShortcutManager:
|
||||
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
|
||||
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
|
||||
keyboard.Key.cmd_l: keyboard.Key.cmd,
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd,
|
||||
}
|
||||
|
||||
return key_mapping.get(key, key)
|
||||
|
||||
return key_mapping.get(key, key)
|
||||
|
||||
+59
-55
@@ -5,7 +5,6 @@ This module provides a system tray indicator for controlling the speech
|
||||
recognition process and displaying its status.
|
||||
"""
|
||||
|
||||
import gi
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
@@ -13,13 +12,18 @@ import sys
|
||||
import threading
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
import gi
|
||||
|
||||
# Import GTK
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import Gtk, AppIndicator3, GObject, GLib
|
||||
from gi.repository import AppIndicator3, GLib, GObject, Gtk
|
||||
|
||||
# Import local modules
|
||||
from speech_recognition.recognition_manager import SpeechRecognitionManager, RecognitionState
|
||||
from speech_recognition.recognition_manager import (
|
||||
RecognitionState,
|
||||
SpeechRecognitionManager,
|
||||
)
|
||||
from text_injection.text_injector import TextInjector
|
||||
from ui.keyboard_shortcuts import KeyboardShortcutManager
|
||||
|
||||
@@ -36,66 +40,66 @@ PROCESSING_ICON = "microphone-process"
|
||||
class TrayIndicator:
|
||||
"""
|
||||
System tray indicator for Vocalinux.
|
||||
|
||||
|
||||
This class provides a system tray icon with a menu for controlling
|
||||
the speech recognition process.
|
||||
"""
|
||||
|
||||
def __init__(self, speech_engine: SpeechRecognitionManager, text_injector: TextInjector):
|
||||
|
||||
def __init__(
|
||||
self, speech_engine: SpeechRecognitionManager, text_injector: TextInjector
|
||||
):
|
||||
"""
|
||||
Initialize the system tray indicator.
|
||||
|
||||
|
||||
Args:
|
||||
speech_engine: The speech recognition manager instance
|
||||
text_injector: The text injector instance
|
||||
"""
|
||||
self.speech_engine = speech_engine
|
||||
self.text_injector = text_injector
|
||||
|
||||
|
||||
# Initialize keyboard shortcut manager
|
||||
self.shortcut_manager = KeyboardShortcutManager()
|
||||
|
||||
|
||||
# Ensure icon directory exists
|
||||
os.makedirs(ICON_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# Register for speech recognition state changes
|
||||
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
|
||||
|
||||
|
||||
# Initialize the icon files
|
||||
self._init_icons()
|
||||
|
||||
|
||||
# Initialize the indicator (in the GTK main thread)
|
||||
GLib.idle_add(self._init_indicator)
|
||||
|
||||
|
||||
# Set up keyboard shortcuts
|
||||
self.shortcut_manager.register_toggle_callback(self._toggle_recognition)
|
||||
self.shortcut_manager.start()
|
||||
|
||||
|
||||
def _init_icons(self):
|
||||
"""Initialize the icon files for the tray indicator."""
|
||||
# TODO: Copy default icons to the icon directory if they don't exist
|
||||
pass
|
||||
|
||||
|
||||
def _init_indicator(self):
|
||||
"""Initialize the system tray indicator."""
|
||||
logger.info("Initializing system tray indicator")
|
||||
|
||||
|
||||
# Create the indicator
|
||||
self.indicator = AppIndicator3.Indicator.new(
|
||||
APP_ID,
|
||||
DEFAULT_ICON,
|
||||
AppIndicator3.IndicatorCategory.APPLICATION_STATUS
|
||||
APP_ID, DEFAULT_ICON, AppIndicator3.IndicatorCategory.APPLICATION_STATUS
|
||||
)
|
||||
|
||||
|
||||
# Set the icon path
|
||||
self.indicator.set_icon_theme_path(ICON_DIR)
|
||||
|
||||
|
||||
# Set the indicator status
|
||||
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
|
||||
|
||||
# Create the menu
|
||||
self.menu = Gtk.Menu()
|
||||
|
||||
|
||||
# Add menu items
|
||||
self._add_menu_item("Start Voice Typing", self._on_start_clicked)
|
||||
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
|
||||
@@ -104,29 +108,29 @@ class TrayIndicator:
|
||||
self._add_menu_separator()
|
||||
self._add_menu_item("About", self._on_about_clicked)
|
||||
self._add_menu_item("Quit", self._on_quit_clicked)
|
||||
|
||||
|
||||
# Set the indicator menu
|
||||
self.indicator.set_menu(self.menu)
|
||||
|
||||
|
||||
# Show the menu
|
||||
self.menu.show_all()
|
||||
|
||||
|
||||
# Update the UI based on the initial state
|
||||
self._update_ui(RecognitionState.IDLE)
|
||||
|
||||
|
||||
return False # Remove idle callback
|
||||
|
||||
|
||||
def _toggle_recognition(self):
|
||||
"""Toggle the recognition state between IDLE and LISTENING."""
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
else:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
|
||||
def _add_menu_item(self, label: str, callback: Callable):
|
||||
"""
|
||||
Add a menu item to the indicator menu.
|
||||
|
||||
|
||||
Args:
|
||||
label: The label for the menu item
|
||||
callback: The callback function to call when the item is clicked
|
||||
@@ -135,26 +139,26 @@ class TrayIndicator:
|
||||
item.connect("activate", callback)
|
||||
self.menu.append(item)
|
||||
return item
|
||||
|
||||
|
||||
def _add_menu_separator(self):
|
||||
"""Add a separator to the indicator menu."""
|
||||
separator = Gtk.SeparatorMenuItem()
|
||||
self.menu.append(separator)
|
||||
|
||||
|
||||
def _on_recognition_state_changed(self, state: RecognitionState):
|
||||
"""
|
||||
Handle changes in the speech recognition state.
|
||||
|
||||
|
||||
Args:
|
||||
state: The new recognition state
|
||||
"""
|
||||
# Update the UI in the GTK main thread
|
||||
GLib.idle_add(self._update_ui, state)
|
||||
|
||||
|
||||
def _update_ui(self, state: RecognitionState):
|
||||
"""
|
||||
Update the UI based on the recognition state.
|
||||
|
||||
|
||||
Args:
|
||||
state: The current recognition state
|
||||
"""
|
||||
@@ -174,13 +178,13 @@ class TrayIndicator:
|
||||
self.indicator.set_icon(DEFAULT_ICON)
|
||||
self._set_menu_item_enabled("Start Voice Typing", True)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", False)
|
||||
|
||||
|
||||
return False # Remove idle callback
|
||||
|
||||
|
||||
def _set_menu_item_enabled(self, label: str, enabled: bool):
|
||||
"""
|
||||
Set the enabled state of a menu item by its label.
|
||||
|
||||
|
||||
Args:
|
||||
label: The label of the menu item
|
||||
enabled: Whether the item should be enabled
|
||||
@@ -189,74 +193,74 @@ class TrayIndicator:
|
||||
if isinstance(item, Gtk.MenuItem) and item.get_label() == label:
|
||||
item.set_sensitive(enabled)
|
||||
break
|
||||
|
||||
|
||||
def _on_start_clicked(self, widget):
|
||||
"""Handle click on the Start Voice Typing menu item."""
|
||||
logger.debug("Start Voice Typing clicked")
|
||||
self.speech_engine.start_recognition()
|
||||
|
||||
|
||||
def _on_stop_clicked(self, widget):
|
||||
"""Handle click on the Stop Voice Typing menu item."""
|
||||
logger.debug("Stop Voice Typing clicked")
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
|
||||
def _on_settings_clicked(self, widget):
|
||||
"""Handle click on the Settings menu item."""
|
||||
logger.debug("Settings clicked")
|
||||
# TODO: Implement settings dialog
|
||||
|
||||
|
||||
def _on_about_clicked(self, widget):
|
||||
"""Handle click on the About menu item."""
|
||||
logger.debug("About clicked")
|
||||
|
||||
|
||||
about_dialog = Gtk.AboutDialog()
|
||||
about_dialog.set_program_name("Vocalinux")
|
||||
about_dialog.set_version("0.1.0")
|
||||
about_dialog.set_copyright("© 2025 Vocalinux Team")
|
||||
about_dialog.set_comments("A seamless voice dictation system for Ubuntu")
|
||||
about_dialog.set_website("https://github.com/vocalinux")
|
||||
about_dialog.set_website("https://github.com/jatinkrmalik/vocalinux")
|
||||
about_dialog.set_website_label("GitHub Repository")
|
||||
about_dialog.set_license_type(Gtk.License.GPL_3_0)
|
||||
|
||||
|
||||
# Run the dialog
|
||||
about_dialog.run()
|
||||
about_dialog.destroy()
|
||||
|
||||
|
||||
def _on_quit_clicked(self, widget):
|
||||
"""Handle click on the Quit menu item."""
|
||||
logger.debug("Quit clicked")
|
||||
self._quit()
|
||||
|
||||
|
||||
def _quit(self):
|
||||
"""Quit the application."""
|
||||
logger.info("Quitting application")
|
||||
|
||||
|
||||
# Stop the keyboard shortcut manager
|
||||
self.shortcut_manager.stop()
|
||||
|
||||
|
||||
Gtk.main_quit()
|
||||
|
||||
|
||||
def run(self):
|
||||
"""Run the application main loop."""
|
||||
logger.info("Starting GTK main loop")
|
||||
|
||||
|
||||
# Set up signal handlers for graceful termination
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
|
||||
# Start the GTK main loop
|
||||
try:
|
||||
Gtk.main()
|
||||
except KeyboardInterrupt:
|
||||
self._quit()
|
||||
|
||||
|
||||
def _signal_handler(self, sig, frame):
|
||||
"""
|
||||
Handle signals (e.g., SIGINT, SIGTERM).
|
||||
|
||||
|
||||
Args:
|
||||
sig: The signal number
|
||||
frame: The current stack frame
|
||||
"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
GLib.idle_add(self._quit)
|
||||
GLib.idle_add(self._quit)
|
||||
|
||||
Reference in New Issue
Block a user