Core implementation: speech recognition, text injection, and UI components

This commit is contained in:
jatinkrmalik
2025-04-07 16:57:17 -05:00
parent c953ef21fd
commit 4b867bb9d4
9 changed files with 791 additions and 0 deletions
View File
+82
View File
@@ -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()
View File
@@ -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
View File
+186
View File
@@ -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}")
View File
+243
View File
@@ -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)