diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..53bed16 --- /dev/null +++ b/setup.py @@ -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", +) \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..44c037e --- /dev/null +++ b/src/main.py @@ -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() \ No newline at end of file diff --git a/src/speech_recognition/__init__.py b/src/speech_recognition/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/speech_recognition/recognition_manager.py b/src/speech_recognition/recognition_manager.py new file mode 100644 index 0000000..8aca394 --- /dev/null +++ b/src/speech_recognition/recognition_manager.py @@ -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 \ No newline at end of file diff --git a/src/text_injection/__init__.py b/src/text_injection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/text_injection/text_injector.py b/src/text_injection/text_injector.py new file mode 100644 index 0000000..183477a --- /dev/null +++ b/src/text_injection/text_injector.py @@ -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}") \ No newline at end of file diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/tray_indicator.py b/src/ui/tray_indicator.py new file mode 100644 index 0000000..191f446 --- /dev/null +++ b/src/ui/tray_indicator.py @@ -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) \ No newline at end of file