Core implementation: speech recognition, text injection, and UI components
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="ubuntu-voice-typing",
|
||||
version="0.1.0",
|
||||
description="A seamless voice dictation system for Ubuntu",
|
||||
author="Ubuntu Voice Typing Team",
|
||||
author_email="project@example.com",
|
||||
packages=find_packages(where="src"),
|
||||
package_dir={"": "src"},
|
||||
install_requires=[
|
||||
"vosk", # For VOSK API speech recognition
|
||||
"pydub", # For audio processing
|
||||
"python-xlib", # For keyboard shortcut handling in X11
|
||||
"pynput", # For keyboard/mouse events
|
||||
"PyGObject", # For GTK UI
|
||||
],
|
||||
extras_require={
|
||||
"whisper": ["whisper", "torch"], # Optional Whisper AI support
|
||||
"dev": [
|
||||
"pytest",
|
||||
"black",
|
||||
"isort",
|
||||
"flake8",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"ubuntu-voice-typing=ubuntu_voice_typing.main:main",
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Desktop Environment :: Gnome",
|
||||
],
|
||||
python_requires=">=3.6",
|
||||
)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main entry point for Ubuntu Voice Typing application.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add package to path for development mode
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from speech_recognition import recognition_manager
|
||||
from text_injection import text_injector
|
||||
from ui import tray_indicator
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description="Ubuntu Voice Typing")
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Enable debug logging"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model", type=str, default="small",
|
||||
help="Speech recognition model size (small, medium, large)"
|
||||
)
|
||||
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"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
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 Ubuntu Voice Typing...")
|
||||
|
||||
# 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()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Speech recognition manager module for Ubuntu Voice Typing.
|
||||
|
||||
This module provides a unified interface to different speech recognition engines,
|
||||
currently supporting VOSK and Whisper.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define constants
|
||||
MODELS_DIR = os.path.expanduser("~/.local/share/ubuntu-voice-typing/models")
|
||||
|
||||
|
||||
class RecognitionState(Enum):
|
||||
"""Enum representing the state of the speech recognition system."""
|
||||
IDLE = auto()
|
||||
LISTENING = auto()
|
||||
PROCESSING = auto()
|
||||
ERROR = auto()
|
||||
|
||||
|
||||
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")
|
||||
"""
|
||||
self.engine = engine
|
||||
self.model_size = model_size
|
||||
self.state = RecognitionState.IDLE
|
||||
self.audio_thread = None
|
||||
self.recognition_thread = None
|
||||
self.model = None
|
||||
self.text_callbacks: List[Callable[[str], None]] = []
|
||||
self.state_callbacks: List[Callable[[RecognitionState], None]] = []
|
||||
|
||||
# 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")
|
||||
|
||||
# Initialize the selected speech recognition engine
|
||||
if engine == "vosk":
|
||||
self._init_vosk()
|
||||
elif engine == "whisper":
|
||||
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
|
||||
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...")
|
||||
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'")
|
||||
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'")
|
||||
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 = {
|
||||
"small": "vosk-model-small-en-us-0.15",
|
||||
"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 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"):
|
||||
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 _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)
|
||||
|
||||
# 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")
|
||||
self._update_state(RecognitionState.IDLE)
|
||||
|
||||
# Stop the audio and recognition threads
|
||||
if self.audio_thread and self.audio_thread.is_alive():
|
||||
# Signal thread to stop
|
||||
self.audio_thread.join(timeout=1.0)
|
||||
|
||||
if self.recognition_thread and self.recognition_thread.is_alive():
|
||||
self.recognition_thread.join(timeout=1.0)
|
||||
|
||||
def _record_audio(self):
|
||||
"""Record audio from the microphone."""
|
||||
# TODO: Implement audio recording based on the selected engine
|
||||
# This will use PyAudio or similar to record audio and feed it to the recognition engine
|
||||
pass
|
||||
|
||||
def _perform_recognition(self):
|
||||
"""Perform speech recognition on the recorded audio."""
|
||||
# TODO: Implement speech recognition based on the selected engine
|
||||
# This will process the audio and call the registered text callbacks with the results
|
||||
pass
|
||||
|
||||
def process_commands(self, text: str) -> str:
|
||||
"""
|
||||
Process text commands in the recognized text.
|
||||
|
||||
Args:
|
||||
text: The recognized text to process
|
||||
|
||||
Returns:
|
||||
The processed text with commands handled
|
||||
"""
|
||||
# TODO: Implement command processing (e.g., "new line", "delete that", etc.)
|
||||
return text
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Text injection module for Ubuntu Voice Typing.
|
||||
|
||||
This module is responsible for injecting recognized text into the active
|
||||
application, supporting both X11 and Wayland environments.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DesktopEnvironment(Enum):
|
||||
"""Enum representing the desktop environment."""
|
||||
X11 = "x11"
|
||||
WAYLAND = "wayland"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
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.WAYLAND:
|
||||
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()
|
||||
|
||||
def _detect_environment(self) -> DesktopEnvironment:
|
||||
"""
|
||||
Detect the current desktop environment (X11 or Wayland).
|
||||
|
||||
Returns:
|
||||
The detected desktop environment
|
||||
"""
|
||||
session_type = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
if session_type == "wayland":
|
||||
return DesktopEnvironment.WAYLAND
|
||||
elif session_type == "x11":
|
||||
return DesktopEnvironment.X11
|
||||
else:
|
||||
# Try to detect based on other methods
|
||||
if "WAYLAND_DISPLAY" in os.environ:
|
||||
return DesktopEnvironment.WAYLAND
|
||||
elif "DISPLAY" in os.environ:
|
||||
return DesktopEnvironment.X11
|
||||
else:
|
||||
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")
|
||||
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
|
||||
|
||||
if not wtype_available and not ydotool_available:
|
||||
logger.error("Neither wtype nor ydotool found for Wayland support. "
|
||||
"Please install one of them:\n"
|
||||
"For wtype: sudo apt install wtype\n"
|
||||
"For ydotool: sudo apt install ydotool")
|
||||
raise RuntimeError("Missing required dependencies for Wayland support")
|
||||
|
||||
self.wayland_tool = "wtype" if wtype_available else "ydotool"
|
||||
logger.info(f"Using {self.wayland_tool} for Wayland text injection")
|
||||
|
||||
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:
|
||||
self._inject_with_xdotool(escaped_text)
|
||||
else:
|
||||
self._inject_with_wayland_tool(escaped_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to inject text: {e}")
|
||||
|
||||
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'\$',
|
||||
}
|
||||
|
||||
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)
|
||||
"""
|
||||
cmd = ["xdotool", "type", "--clearmodifiers", text]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
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)
|
||||
"""
|
||||
if self.wayland_tool == "wtype":
|
||||
cmd = ["wtype", text]
|
||||
else: # ydotool
|
||||
cmd = ["ydotool", "type", text]
|
||||
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
def inject_special_key(self, key: str):
|
||||
"""
|
||||
Inject a special key or key combination.
|
||||
|
||||
Args:
|
||||
key: The key to inject (e.g., "Return", "BackSpace", etc.)
|
||||
"""
|
||||
logger.debug(f"Injecting special key: {key}")
|
||||
|
||||
try:
|
||||
if self.environment == DesktopEnvironment.X11:
|
||||
subprocess.run(["xdotool", "key", "--clearmodifiers", key], check=True)
|
||||
else:
|
||||
if self.wayland_tool == "wtype":
|
||||
subprocess.run(["wtype", f"-k {key}"], check=True)
|
||||
else: # ydotool
|
||||
subprocess.run(["ydotool", "key", key], check=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to inject special key: {e}")
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
System tray indicator module for Ubuntu Voice Typing.
|
||||
|
||||
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
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
# Import GTK
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import Gtk, AppIndicator3, GObject, GLib
|
||||
|
||||
# Import local modules
|
||||
from speech_recognition.recognition_manager import SpeechRecognitionManager, RecognitionState
|
||||
from text_injection.text_injector import TextInjector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define constants
|
||||
APP_ID = "ubuntu-voice-typing"
|
||||
ICON_DIR = os.path.expanduser("~/.local/share/ubuntu-voice-typing/icons")
|
||||
DEFAULT_ICON = "microphone-off"
|
||||
ACTIVE_ICON = "microphone"
|
||||
PROCESSING_ICON = "microphone-process"
|
||||
|
||||
|
||||
class TrayIndicator:
|
||||
"""
|
||||
System tray indicator for Ubuntu Voice Typing.
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# 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)
|
||||
self._add_menu_separator()
|
||||
self._add_menu_item("Settings", self._on_settings_clicked)
|
||||
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 _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
|
||||
"""
|
||||
item = Gtk.MenuItem.new_with_label(label)
|
||||
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
|
||||
"""
|
||||
if state == RecognitionState.IDLE:
|
||||
self.indicator.set_icon(DEFAULT_ICON)
|
||||
self._set_menu_item_enabled("Start Voice Typing", True)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", False)
|
||||
elif state == RecognitionState.LISTENING:
|
||||
self.indicator.set_icon(ACTIVE_ICON)
|
||||
self._set_menu_item_enabled("Start Voice Typing", False)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", True)
|
||||
elif state == RecognitionState.PROCESSING:
|
||||
self.indicator.set_icon(PROCESSING_ICON)
|
||||
self._set_menu_item_enabled("Start Voice Typing", False)
|
||||
self._set_menu_item_enabled("Stop Voice Typing", True)
|
||||
elif state == RecognitionState.ERROR:
|
||||
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
|
||||
"""
|
||||
for item in self.menu.get_children():
|
||||
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("Ubuntu Voice Typing")
|
||||
about_dialog.set_version("0.1.0")
|
||||
about_dialog.set_copyright("© 2025 Ubuntu Voice Typing Team")
|
||||
about_dialog.set_comments("A seamless voice dictation system for Ubuntu")
|
||||
about_dialog.set_website("https://github.com/ubuntu-voice-typing")
|
||||
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")
|
||||
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)
|
||||
Reference in New Issue
Block a user