#!/usr/bin/env python3 """ Main entry point for Voculinux application. """ import argparse import atexit import logging import queue import sys import threading import time from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Dict, Optional from .app_identity import APP_ID, APP_NAME, CLI_PROG from .utils.host_process import running_from_appimage from .utils.vosk_model_info import SUPPORTED_LANGUAGES from .version import __version__ # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) # Note: GTK-dependent modules (tray_indicator) are imported lazily after # dependency checking to provide better error messages for pip/pipx users # Keep CLI --language choices in sync with the Settings catalog. LANGUAGE_CHOICES = tuple(SUPPORTED_LANGUAGES.keys()) def _should_append_trailing_space() -> bool: """Return whether completed transcriptions should get a trailing space. Reads config.json from disk on each call so Settings toggles take effect immediately. Historically TrayIndicator and main() each constructed their own ConfigManager, so an in-memory read would miss Settings writes; the instance is shared now, but the disk read stays as the conservative path (same pattern as TextInjector._should_copy_to_clipboard). """ try: import json import os from .utils.paths import config_dir config_path = os.path.join(config_dir(), "config.json") if os.path.exists(config_path): with open(config_path, "r") as f: config = json.load(f) return bool(config.get("text_injection", {}).get("append_trailing_space", True)) except Exception as e: logger.debug(f"Could not read append_trailing_space setting: {e}") return True def parse_arguments(): """Parse command line arguments.""" parser = argparse.ArgumentParser(prog=CLI_PROG, description=APP_NAME) parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}", help=f"Show the installed {APP_NAME} version and exit", ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") # default model, language and engine are loaded from default config # due to priority of args over config parser.add_argument( "--model", type=str, help=( "Speech recognition model ID. Examples: small, medium, large, " "medium.en-q5_0, large-v3-turbo" ), ) parser.add_argument( "--language", type=str, choices=LANGUAGE_CHOICES, help=( "Speech recognition language (auto for auto-detect, or a code " "from the Settings language catalog such as en-us, hu, ja, …)" ), ) parser.add_argument( "--engine", type=str, choices=["vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api"], help="Speech recognition engine to use (whisper_cpp recommended for best performance)", ) parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode") parser.add_argument( "--start-minimized", action="store_true", help="Start minimized to system tray", ) # External activation triggers: forward a control command to a running # instance over D-Bus (e.g. from a KDE Plasma global shortcut) and exit. parser.add_argument( "--toggle", action="store_true", help="Toggle voice typing on a running instance (via D-Bus) and exit", ) parser.add_argument( "--start", action="store_true", help="Start voice typing on a running instance (via D-Bus) and exit", ) parser.add_argument( "--stop", action="store_true", help="Stop voice typing on a running instance (via D-Bus) and exit", ) parser.add_argument( "--dictionary-file", type=str, help=( "Use this custom terms file for this session only (UTF-8, one term per line); " "it does not change saved settings" ), ) parser.add_argument( "--transcribe-file", type=str, metavar="PATH", help=( "Transcribe an audio file with speaker attribution using the " "TinyDiarize model, print the transcript, and exit" ), ) return parser.parse_args() # CLI flags that trigger a running instance instead of starting a new one. _TRIGGER_FLAGS = ("toggle", "start", "stop") def _selected_trigger(args: argparse.Namespace) -> Optional[str]: """Return the external-activation command requested via CLI, if any.""" for name in _TRIGGER_FLAGS: # Explicit `is True` guards against MagicMock args in tests, whose # attributes are truthy by default. if getattr(args, name, False) is True: return name return None def _dispatch_trigger(command: str) -> int: """Forward a control command to a running instance over D-Bus. Returns a process exit code (0 on success, 1 if no instance is reachable). """ from .dbus_service import send_command if send_command(command): logger.info("Sent '%s' command to running Voculinux instance", command) return 0 logger.error( "Could not reach a running Voculinux instance to '%s'. Is Voculinux running?", command, ) return 1 def _run_file_transcription(path: str) -> int: """Headless ``--transcribe-file`` path: transcribe and print, no GTK.""" from .speech_recognition.diarization import format_transcript, transcribe_audio_file try: blocks = transcribe_audio_file(path) except (OSError, ValueError, RuntimeError) as error: print( f"{CLI_PROG}: could not transcribe {path}: {type(error).__name__}: {error}", file=sys.stderr, ) return 1 transcript = format_transcript(blocks) print(transcript if transcript else f"{CLI_PROG}: no speech detected") return 0 def check_dependencies(): """Check for required dependencies and provide helpful error messages.""" missing_system_deps = [] missing_python_deps = [] # Check for GTK3 try: import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa: F401 except (ImportError, ValueError) as e: logger.debug("GTK import failed: %s", e) missing_system_deps.append( "GTK3 (install with: sudo apt install python3-gi gir1.2-gtk-3.0)" ) # Match tray_indicator.py, which never loads the glib indicator from the # AppImage: the bundle's GLib is older than that library needs. indicator_typelibs = [ (namespace, version) for namespace, version in _APPINDICATOR_TYPELIBS if namespace != "AyatanaAppIndicatorGlib" or not running_from_appimage() ] if not any(_typelib_available(namespace, version) for namespace, version in indicator_typelibs): missing_system_deps.append( "AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon" ) # Keyboard backends are optional and checked lazily by the shortcut manager. # Importing pynput can fail on Wayland/X-less sessions even when installed. # requests is used by various components and should remain a required check. try: import requests # noqa: F401 except ImportError: missing_python_deps.append("requests (install with: pip install requests)") if missing_system_deps or missing_python_deps: logger.error("Missing required dependencies:") for dep in missing_system_deps + missing_python_deps: logger.error(f" - {dep}") if missing_system_deps: logger.error("") logger.error("System GTK packages are required. Install them first:") logger.error("") logger.error(" Ubuntu/Debian:") logger.error( " sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1" ) logger.error("") logger.error(" NOTE: On GNOME Shell (default on Debian), you also need:") logger.error(" sudo apt install gnome-shell-extension-appindicator") logger.error(" Then log out and back in. Ubuntu includes this by default.") logger.error("") logger.error(" Fedora:") logger.error(" sudo dnf install python3-gobject gtk3 libayatana-appindicator-gtk3") logger.error("") logger.error(" Arch Linux:") logger.error(" sudo pacman -S python-gobject gtk3 libayatana-appindicator") logger.error("") logger.error(" openSUSE Tumbleweed:") logger.error( " PYVER=$(python3 -c 'import sys; " 'print(f"python{sys.version_info.major}{sys.version_info.minor}")\')' ) logger.error( ' sudo zypper install "${PYVER}-gobject" gtk3 ' "typelib-1_0-AyatanaAppIndicator3-0_1 " "typelib-1_0-Notify-0_7 libnotify4" ) logger.error("") logger.error( "For pipx users: Install system packages BEFORE running 'pipx install voculinux'" ) logger.error("") logger.error("For the best experience, use the recommended installer:") logger.error( " curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh | bash" ) return False return True # Tray indicator typelibs, most preferred first (same order as # tray_indicator.py): libayatana-appindicator-glib, Ayatana AppIndicator # (maintained; registers on KDE Plasma), its rare lowercase typelib, then # the legacy Canonical AppIndicator3 as last resort. _APPINDICATOR_TYPELIBS = ( ("AyatanaAppIndicatorGlib", "2.0"), ("AyatanaAppIndicator3", "0.1"), ("AyatanaAppindicator3", "0.1"), ("AppIndicator3", "0.1"), ) def _typelib_available(namespace: str, version: str) -> bool: """Return True when the GObject Introspection namespace can be imported.""" try: import gi gi.require_version(namespace, version) getattr(__import__("gi.repository", fromlist=[namespace]), namespace) except (ImportError, ValueError, AttributeError) as e: logger.debug("%s import failed: %s", namespace, e) return False return True def check_display_available(): """Check if a display is available for GTK.""" try: import gi gi.require_version("Gdk", "3.0") from gi.repository import Gdk display = Gdk.Display.get_default() if display is None: logger.error("No display available. Voculinux requires a graphical environment.") logger.error("") logger.error("If running remotely, ensure DISPLAY is set:") logger.error(" export DISPLAY=:0") logger.error("") logger.error("If running in a headless environment, Voculinux cannot run.") return False return True except Exception as e: logger.error(f"Failed to initialize display: {e}") return False def check_appindicator_support(): try: from gi.repository import Gio proxy = Gio.DBusProxy.new_for_bus_sync( Gio.BusType.SESSION, Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION, None, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", None, ) names_variant = proxy.call_sync( "ListNames", None, Gio.DBusCallFlags.NONE, -1, None, ) if names_variant is not None: name_list = names_variant.unpack()[0] return "org.kde.StatusNotifierWatcher" in name_list except Exception: pass return True def main(): """Main entry point for the application.""" # Parse arguments first so flags like --version work even when # another instance already holds the single-instance lock args = parse_arguments() # Configure debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) logger.debug("Debug logging enabled") # External activation: forward the command to a running instance over D-Bus # and exit without acquiring the lock or starting a second full instance. trigger = _selected_trigger(args) if trigger is not None: sys.exit(_dispatch_trigger(trigger)) # Headless file transcription exits before the instance lock and GTK, so # it works alongside a running tray app and on a displayless shell. if isinstance(args.transcribe_file, str): sys.exit(_run_file_transcription(args.transcribe_file)) # Check for single instance BEFORE any initialization from . import single_instance from .post_processor import apply_post_processing if not single_instance.acquire_lock(): # Another instance is already running - show notification and exit try: from gi.repository import Notify Notify.init(APP_NAME) notification = Notify.Notification.new( APP_NAME, "Another instance is already running. Only one instance is allowed at a time.", "dialog-error", ) notification.show() # Give notification time to display before exiting time.sleep(0.5) except Exception: # Fallback if notification fails (e.g., no display) pass sys.exit(1) # Register cleanup to release lock on exit atexit.register(single_instance.release_lock) # One-time legacy Voculinux import (ticket #18): copies a previous # install's config, dictionary, and portal token into the new locations # before anything reads them. No-op on every later run. try: from .legacy_import import import_legacy_once import_legacy_once() except Exception as error: logger.debug("Legacy import skipped: %s", error) # Check dependencies first (before importing GTK-dependent modules) if not check_dependencies(): logger.error("Cannot start Voculinux due to missing dependencies") sys.exit(1) # Identity for WM class / AppIndicator title (otherwise shows as main.py) import gi gi.require_version("GLib", "2.0") from gi.repository import GLib GLib.set_prgname(APP_ID) GLib.set_application_name(APP_NAME) try: gi.require_version("Gdk", "3.0") from gi.repository import Gdk Gdk.set_program_class(APP_NAME) except Exception: pass # Check if display is available before creating any GTK widgets if not check_display_available(): sys.exit(1) from .utils.gtk_color_scheme import apply_brand_accent, apply_os_color_scheme apply_os_color_scheme() apply_brand_accent() if not check_appindicator_support(): logger.warning("No StatusNotifierWatcher found on D-Bus session bus.") logger.warning("The system tray icon may not appear.") logger.warning("") logger.warning("If you are using GNOME Shell, install the AppIndicator extension:") logger.warning(" Debian: sudo apt install gnome-shell-extension-appindicator") logger.warning(" Fedora: sudo dnf install gnome-shell-extension-appindicator") logger.warning(" Arch: sudo pacman -S gnome-shell-extension-appindicator") logger.warning("") logger.warning("After installing, log out and back in (or restart GNOME Shell).") # Now it's safe to import GTK-dependent modules import os from .common_types import RecognitionState from .custom_dictionary import CustomDictionaryManager from .speech_recognition import recognition_manager from .text_injection import focused_window, text_injector from .ui import tray_indicator from .ui.action_handler import ActionHandler from .ui.config_manager import get_shared_config_manager from .ui.logging_manager import initialize_logging from .ui.transcription_history import ( DEFAULT_MAX_ITEMS, TranscriptionHistory, sanitize_max_items, ) from .utils.paths import data_dir # Initialize logging manager early initialize_logging() logger.info("Logging system initialized") # Try to start IBus daemon if not running (for text injection) # This helps on desktop environments where IBus doesn't start automatically try: from .text_injection import start_ibus_daemon if start_ibus_daemon(): logger.debug("IBus daemon started for text injection") except Exception as e: logger.debug(f"Could not start IBus daemon: {e}") config_manager = get_shared_config_manager() saved_settings = config_manager.get_settings().get("speech_recognition", {}) audio_settings = config_manager.get_settings().get("audio", {}) general_settings = config_manager.get_settings().get("general", {}) first_run = general_settings.get("first_run", True) should_prompt_first_run = first_run and not args.start_minimized if should_prompt_first_run: from .ui.first_run_dialog import show_first_run_dialog result = show_first_run_dialog() if result == "yes": from .ui import autostart_manager if autostart_manager.set_autostart(True): config_manager.set("general", "autostart", True) else: config_manager.set("general", "autostart", False) elif result == "no": from .ui import autostart_manager autostart_manager.set_autostart(False) config_manager.set("general", "autostart", False) if result in {"yes", "no"}: config_manager.set("general", "first_run", False) config_manager.save_settings() # CLI arguments take precedence over saved config # We need to check if the user explicitly provided arguments # by examining sys.argv since argparse defaults don't tell us this cli_engine_set = any(arg.startswith("--engine") for arg in sys.argv[1:]) cli_model_set = any(arg.startswith("--model") for arg in sys.argv[1:]) cli_language_set = any(arg.startswith("--language") for arg in sys.argv[1:]) # Use CLI args if explicitly set, otherwise fall back to saved config, then defaults if cli_engine_set: engine = args.engine logger.info(f"Using engine={engine} (from command line)") else: engine = saved_settings.get("engine", args.engine) logger.info(f"Using engine={engine} (from saved config)") if cli_language_set: language = args.language logger.info(f"Using language={language} (from command line)") else: language = saved_settings.get("language", args.language) logger.info(f"Using language={language} (from saved config)") # Parakeet coverage is the model, not a catalog language. Normalize after # CLI vs saved resolution so --language / stale config cannot leave an # unused value on SpeechRecognitionManager. resolved_language = language language = recognition_manager.normalize_language_for_engine(engine, language) if language != resolved_language: logger.info( "Parakeet ignores catalog language; using language=auto " f"(was {resolved_language})" ) if cli_model_set: model_size = args.model logger.info(f"Using model={model_size} (from command line)") else: # Resolve per engine: the generic "model_size" key always holds the # model of whichever engine was saved last, so reading it directly # loads the wrong model whenever the two disagree. model_size = config_manager.get_model_size_for_engine(engine) logger.info(f"Using model={model_size} (saved for engine {engine})") vad_sensitivity = saved_settings.get("vad_sensitivity", 3) silence_timeout = saved_settings.get("silence_timeout", 1.2) stop_sound_guard_ms = saved_settings.get("stop_sound_guard_ms", 200) voice_commands_enabled = saved_settings.get("voice_commands_enabled") # None = auto audio_device_index = audio_settings.get("device_index", None) audio_device_name = audio_settings.get("device_name", None) advanced_settings = config_manager.get_settings().get("advanced", {}) history_settings = config_manager.get_settings().get("history", {}) history_enabled = bool(history_settings.get("enabled", True)) # A hand-edited config.json can hold a non-numeric or out-of-range value; # sanitize on load so a bad preference cannot abort startup. history_max_items = sanitize_max_items(history_settings.get("max_items", DEFAULT_MAX_ITEMS)) # Opt-in on-disk record of snippets (#758). Off by default: dictated # text stays memory-only unless the user asks for it to persist. history_persist = bool(history_settings.get("persist", False)) dictionary_file = getattr(args, "dictionary_file", None) transient_terms_path = ( dictionary_file.strip() if isinstance(dictionary_file, str) and dictionary_file.strip() else None ) if transient_terms_path is not None: logger.info("Using session-only custom terms file: %s", transient_terms_path) dictionary_manager = CustomDictionaryManager(config_manager, transient_terms_path) logger.info(f"Final settings: engine={engine}, language={language}, model={model_size}") if audio_device_index is not None: logger.info( f"Using audio device index={audio_device_index} " f"(name={audio_device_name}, from saved config)" ) # Initialize main components logger.info("Initializing Voculinux...") try: # Initialize speech recognition engine with saved/configured settings speech_engine = recognition_manager.SpeechRecognitionManager( engine=engine, model_size=model_size, language=language, vad_sensitivity=vad_sensitivity, silence_timeout=silence_timeout, stop_sound_guard_ms=stop_sound_guard_ms, buffer_during_reload=config_manager.get_bool( "model_keepalive", "buffer_during_reload", False ), voice_commands_enabled=voice_commands_enabled, audio_device_index=audio_device_index, audio_device_name=audio_device_name, whispercpp_no_timestamps=advanced_settings.get("whispercpp_no_timestamps", True), whispercpp_no_context=advanced_settings.get("whispercpp_no_context", True), whispercpp_initial_prompt=advanced_settings.get("whispercpp_initial_prompt", ""), whispercpp_language_candidates=advanced_settings.get( "whispercpp_language_candidates", "" ), whispercpp_temperature=advanced_settings.get("whispercpp_temperature", 0.0), whispercpp_temperature_inc=advanced_settings.get("whispercpp_temperature_inc", -1.0), whispercpp_entropy_thold=advanced_settings.get("whispercpp_entropy_thold", 2.4), whispercpp_logprob_thold=advanced_settings.get("whispercpp_logprob_thold", -1.0), whispercpp_no_speech_thold=advanced_settings.get("whispercpp_no_speech_thold", 0.6), whispercpp_n_threads=advanced_settings.get("whispercpp_n_threads", 0), whispercpp_gpu_device=advanced_settings.get("whispercpp_gpu_device", None), dictionary_manager=dictionary_manager, remote_api_url=saved_settings.get("remote_api_url", ""), remote_api_key=saved_settings.get("remote_api_key", ""), remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"), remote_api_model=saved_settings.get("remote_api_model", "whisper-1"), ) # Initialize text injection system text_system = text_injector.TextInjector(wayland_mode=args.wayland) # Initialize action handler action_handler = ActionHandler(text_system) # Transcription history: a newest-first list of recent dictation # snippets surfaced in the tray menu. One snippet == one dictation # session (everything said between start and stop). Memory-only by # default; history.persist mirrors it to history.jsonl under the # data directory so snippets survive restarts (#758). transcription_history = TranscriptionHistory( max_items=history_max_items, enabled=history_enabled, persist=history_persist, store_path=os.path.join(data_dir(), "history.jsonl"), ) def _history_record_fields() -> Dict[str, Any]: """Optional details stored with a persisted snippet (#758). Language and model come off the engine; commit sites that know the session bounds add duration themselves. """ fields: Dict[str, Any] = {} language = getattr(speech_engine, "_session_language", None) or getattr( speech_engine, "language", None ) if language: fields["language"] = language engine = getattr(speech_engine, "engine", "") model_size = getattr(speech_engine, "model_size", "") model = "/".join(str(part) for part in (engine, model_size) if part) if model: fields["model"] = model return fields # Segments dictated during the open session, joined and committed to # history when the session ends (state returns to IDLE). Each entry # keeps the monotonic time its audio capture began: a history.clear() # lands mid-decode, so capture time — not arrival order — decides # whether a segment counts as pre-clear speech (dropped) or new # dictation (kept). # # Session association: stop_recognition() emits IDLE after only a # bounded wait on the recognition worker, so a slow final segment can # still fire its text callback afterwards. Each segment must land in # the session that produced it: while a session is open, segments # accumulate in session_segments; a segment arriving on a worker that # is not the open session's worker (or while no session is open) is a # leftover of the just-ended session and is folded into its snippet # instead of leaking into the next one. session_lock = threading.Lock() session_segments: list[tuple[str, float]] = [] session_open = False # Monotonic time the open session started; a segment delivered on the # live worker whose capture predates it belongs to an earlier session. session_started_floor = 0.0 # Worker thread that produced the open session's segments; used to # detect callbacks from a previous session's still-running worker. session_worker: Optional[threading.Thread] = None # Id of the just-closed session's snippet (from # TranscriptionHistory.add), so late segments extend that entry # rather than whichever snippet happens to be newest. latest_snippet_id: Optional[int] = None # Commit epoch of latest_snippet_id, for guarded late merges. latest_snippet_epoch = transcription_history.epoch # Snippet each ended session's worker owns, as # ``worker -> (snippet_id, commit epoch)``: a worker's late deliveries # merge into its own session's entry however many sessions have # committed since, so one session's stragglers can never leak into a # newer entry or split across several snippets. ended_worker_snippets: dict[threading.Thread, tuple[int, int]] = {} # Record fields (language, model) captured when a session opens, so a # snippet committed after the engine has already moved on still # carries the language that produced its audio (#758). session_record_fields: Optional[Dict[str, Any]] = None # Fields of the most recently ended session, for late deliveries that # open their own snippet: they belong to that session, not the live # one the engine is already configured for. ended_session_fields: Dict[str, Any] = {} # Per-worker copy of those fields, mirroring ended_worker_snippets: # a straggler keeps its own session's language across sessions. ended_worker_fields: dict[threading.Thread, Dict[str, Any]] = {} # Every worker that has delivered in-session segments; a delivery on # a thread never associated with a session is treated as the # just-ended session's trailing decode, while a worker seen producing # an earlier session can never merge into a newer entry. Dead # workers are pruned so long runs don't accumulate finished threads. session_workers_seen: set[threading.Thread] = set() # Clear epoch the most-recently-ended session was committed under; # late segments merging into its snippet are judged against it. ended_session_epoch = transcription_history.epoch # In-app dictation pad: the Wayland-proof fallback target that # receives dictated text when "dictate to pad" capture is enabled # (#726). Constructed before the tray so the tray menu can open it. from .ui.dictation_pad import DictationPad dictation_pad = DictationPad( enabled=config_manager.is_dictate_to_pad_enabled(), config_manager=config_manager, ) def dictate_to_pad_enabled() -> bool: """Read the live capture toggle from the shared config manager. The shared manager's in-memory cache is updated by Settings and the pad checkbox before save_config rewrites config.json, so this can never flip on a torn mid-write disk read and send dictated text to whichever application holds focus. """ return bool(config_manager.is_dictate_to_pad_enabled()) # Where the last delivered segment went. "delete that" follows the # text, not the current toggle: the capture setting may have flipped # since the segment was delivered. last_injected = {"to_pad": False} # --- Callback wiring --------------------------------------------------- # The speech engine emits four kinds of events, each handled by a # dedicated callback registered below: # # segment_callback(text: str, started_at: float) # Fires for each finalised segment with the monotonic time its # audio capture began, just before the text callbacks. Records # the segment into the transcription history. # # text_callback(text: str) # Called on the recognition thread when a transcription segment # is finalised. The wrapper below strips whitespace, optionally # appends a trailing space (or legacy leading separator), injects # the text, and records it so "delete that" can undo it. # # action_callback(action: str) -> bool # Called when a voice command (e.g. "undo", "select all") is # recognised. Delegated directly to ActionHandler.handle_action. # # state_callback(state: RecognitionState) # Called whenever the engine transitions state (IDLE → LISTENING, # etc.). Used here to clear the "last injected" buffer after a # listening session ends. # ------------------------------------------------------------------ def _normalize_segment_text(text: str) -> str: """Segment text as it is injected: stripped, auto-capitalized (Vosk).""" # Strip only horizontal whitespace: "new line" / "new paragraph" # produce leading newlines a bare lstrip() would silently destroy. normalized = text.lstrip(" \t").rstrip(" \t") # Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing) if ( normalized and config_manager.get("text_injection", "auto_capitalize") and speech_engine.engine == "vosk" ): from vocalinux.speech_recognition.command_processor import capitalize_sentences normalized = capitalize_sentences(normalized) return normalized def record_history_segment(segment: str, started_at: float) -> None: """File a recognized segment under the dictation session it came from. Runs on the recognition worker thread as a segment callback, so ``started_at`` is the moment the segment's audio capture began. While a session is open, segments accumulate into that session's pending snippet; a segment delivered after the session was finalized — the worker can outlive the manager's bounded stop wait and emit text after IDLE — is folded into its own session's snippet rather than the next one. Segments whose capture began before the most recent history.clear() are refused: their audio can only contain pre-clear speech, however late the decode finishes. Segments captured after the clear are kept, so dictation that continues across a clear is still recoverable. """ nonlocal session_worker, latest_snippet_id, latest_snippet_epoch nonlocal session_workers_seen, ended_worker_snippets, ended_worker_fields if not transcription_history.enabled: return segment = _normalize_segment_text(segment) if not segment: return worker = threading.current_thread() # The engine's live worker, when it exposes one: a segment from # any other thread is a leftover from an older session. current_worker = getattr(speech_engine, "recognition_thread", None) with session_lock: # Dead workers cannot deliver again; drop them so long runs # don't accumulate finished threads. session_workers_seen = {t for t in session_workers_seen if t.is_alive()} ended_worker_snippets = { w: s for w, s in ended_worker_snippets.items() if w.is_alive() } ended_worker_fields = {w: f for w, f in ended_worker_fields.items() if w.is_alive()} if started_at <= transcription_history.cleared_at: # Captured before the last clear — must not re-enter. return # Segments captured while the Settings mic test runs are test # speech, not dictation. The [floor, ceiling) window survives # the test's end, so segments still decoding after it cannot # leak into history. test_floor = getattr(speech_engine, "test_capture_floor", None) test_ceiling = getattr(speech_engine, "test_capture_ceiling", None) if ( isinstance(test_floor, (int, float)) and started_at >= test_floor and (not isinstance(test_ceiling, (int, float)) or started_at < test_ceiling) ): return if ( session_open and started_at >= session_started_floor and ( worker is session_worker or worker is current_worker # When the engine exposes no worker (tests, mocks), the # first segment of a session tags it. or ( session_worker is None and not isinstance(current_worker, threading.Thread) ) ) ): session_worker = worker session_workers_seen.add(worker) session_segments.append((segment, started_at)) return # Late segment from a session that already ended: merge into # its own session's snippet, found by the worker delivering # it — a newer session may already have committed on top, so # the newest entry is not the target. A delivery on a thread # never associated with a session is treated as the # just-ended session's trailing decode while a session is # still open; a worker seen producing an earlier session can # never merge into a newer entry. Every write is guarded by # the snippet's own commit epoch, so a clear() landing # between that commit and this delivery still refuses the # text. owner = ended_worker_snippets.get(worker) if owner is not None and transcription_history.extend_entry( owner[0], segment, expected_epoch=owner[1] ): return if ( latest_snippet_id is not None and session_open and worker not in session_workers_seen and transcription_history.extend_entry( latest_snippet_id, segment, expected_epoch=latest_snippet_epoch ) ): return # Otherwise the late segments are the session's only output # and form their own snippet, which its worker keeps owning. # The fields come from the session that produced the audio, # not the one the engine may already have moved on to. owner_fields = ended_worker_fields.get(worker, ended_session_fields) snippet_id = transcription_history.add( segment, expected_epoch=ended_session_epoch, **owner_fields ) if snippet_id is not None: latest_snippet_id = snippet_id latest_snippet_epoch = ended_session_epoch ended_worker_snippets[worker] = (snippet_id, ended_session_epoch) ended_worker_fields[worker] = owner_fields session_workers_seen.add(worker) def inject_transcription(text_to_inject: str, to_pad: Optional[bool] = None) -> None: """Apply the separator rules and inject one finalised segment. Args: text_to_inject: Post-processed text ready for the text injector. to_pad: Destination decided when the job's focus check ran; the delivery must reuse that same decision — re-reading the live toggle here could disagree with the check and send the text somewhere it was never verified for. """ # Read from disk so the Settings toggle applies without restart. append_trailing_space = _should_append_trailing_space() if append_trailing_space: # Put the separator into the previous field so the next session # (push-to-talk / toggle) continues cleanly without needing # cross-session memory — and without a leading space in empty # fields. Skip after newlines from "new line" / "new paragraph". if not text_to_inject.endswith((" ", "\t", "\n")): text_to_inject += " " logger.debug("Appended trailing space after transcription segment") elif action_handler.last_injected_text and action_handler.last_injected_text.strip(): # Legacy: leading space between consecutive in-session segments. text_to_inject = " " + text_to_inject logger.debug("Added space separator before new segment") captured = dictate_to_pad_enabled() if to_pad is None else to_pad if captured: # In-app capture: skip cross-application injection entirely # and land the text in the pad instead (#726). dictation_pad.append_text(text_to_inject) success = True else: success = text_system.inject_text(text_to_inject) if success: action_handler.set_last_injected_text(text_to_inject) last_injected["to_pad"] = captured else: # Deletion state must match what reached the app: a partial # failure counts the confirmed prefix, an unknowable count # (-1) clears it, and 0 leaves the previous segment's state — # a failed injection types nothing, so "delete that" still # means the segment before it. typed = text_system.last_typed_count if isinstance(typed, int) and typed > 0: action_handler.set_last_injected_text(text_to_inject[:typed]) elif isinstance(typed, int) and typed < 0: action_handler.set_last_injected_text("") # Post-processing runs a user executable that may take seconds per # segment. Running it on the recognition thread would stall the # consumer of the bounded audio-segment queue, and once that fills, # queued dictation is dropped. A dedicated worker applies the script # and injects in order; its unbounded backlog waits instead of losing # speech, and a timed-out script falls back to the original text. # # Every segment goes through this one worker — when no script is # configured apply_post_processing is a pass-through — so clearing the # script path mid-queue can never let a later segment overtake an # earlier one still waiting behind a running script. post_processing_executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix="voculinux-post-processing" ) # Cleared by the quit hook so a queued or in-flight job drops its # segment instead of injecting while the injector is being stopped. accepting_injections = threading.Event() accepting_injections.set() # Serialises the flag check and the inject call itself: quitting can # wait out an injection already in progress while never blocking on a # queued or running script. injection_lock = threading.Lock() # Segments that may sit in the backlog (a script is configured, or a # job is still running) are bound to the app focused at submit time; # the worker drops them if focus has moved to another application. pending_jobs = 0 pending_jobs_lock = threading.Lock() def _focused_app_unchanged( target: Optional[focused_window.FocusedWindow], ) -> bool: """Return True unless focus verifiably moved to a different app. A missing baseline or a failed re-probe stays permissive: without a reliable identity the segment keeps the injector's own targeting. """ if target is None: return True current = focused_window.get_focused_window() if current is None: return True return current.identity_blob() == target.identity_blob() def _start_focus_probe() -> queue.Queue[Optional[focused_window.FocusedWindow]]: """Probe the focused window now, off the calling thread. Focus probes shell out to compositor tools under their own timeouts, so each runs on a fresh daemon thread: the recognition thread never waits on them, and — unlike a shared worker — an earlier slow probe cannot delay this one past the focus change it is meant to capture. """ probe_result: queue.Queue[Optional[focused_window.FocusedWindow]] = queue.Queue( maxsize=1 ) def probe() -> None: try: probe_result.put(focused_window.get_focused_window()) except (OSError, RuntimeError, queue.Full) as exc: # get_focused_window() reports Optional and should never # raise; if one escapes anyway the failed probe is # indistinguishable from unavailable focus information # unless it is logged — and the waiting job must still # be released. logger.exception("Focus probe crashed unexpectedly: %s", exc) probe_result.put(None) threading.Thread(target=probe, daemon=True, name="voculinux-focus-probe").start() return probe_result def _probe_result( probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]], ) -> Optional[focused_window.FocusedWindow]: """Return the focus identity a submit-time probe captured. The probe thread starts the moment the segment is submitted, so joining it here adds no wait beyond the probe's own runtime. """ if probe is None: return None return probe.get() def post_process_and_inject( text_to_inject: str, target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]], ) -> None: """Apply the configured post-processing script, then inject. Runs on the post-processing worker, never the recognition thread. An unexpected failure falls back to the unprocessed segment so dictation is never lost inside the worker. Args: text_to_inject: Finalised transcription segment. target_probe: Probe of the app focused when the segment was dictated; the segment is dropped if focus has since moved elsewhere. None keeps the injector's own targeting. """ nonlocal pending_jobs try: if not accepting_injections.is_set(): return try: processed_text = apply_post_processing(text_to_inject, config_manager) except Exception: logger.exception("Post-processing raised unexpectedly; injecting original text") processed_text = text_to_inject if processed_text is None or not accepting_injections.is_set(): return # Pad-bound segments land in the dictation pad regardless of # where focus sits, so a focus change since the segment was # dictated must not drop them. The destination is decided # once here and passed to the injector: re-reading the # toggle at delivery could flip it after the check was # skipped and send the segment to the focused application. to_pad = dictate_to_pad_enabled() if not to_pad and not _focused_app_unchanged(_probe_result(target_probe)): logger.info("Dropping queued segment: focus moved to another application") return with injection_lock: if not accepting_injections.is_set(): return inject_transcription(processed_text, to_pad) finally: with pending_jobs_lock: pending_jobs -= 1 def _run_action( action: str, target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]], ) -> bool: """Run one voice-command action on the post-processing worker. Actions send keystrokes through the same injector as transcription text, so they queue on the same worker in spoken order and are bound to the app focused when the command was issued: even a submission that looks immediate can run after a context switch, so the binding applies to every action. The job waits on the probe for its full duration rather than racing it — the probe's compositor calls carry their own one-second timeouts and short-circuit on tools that are absent, so it answers in milliseconds on a healthy desktop and always terminates; a valid command is therefore never discarded over timing, and the verified result is the only thing that can drop it. """ nonlocal pending_jobs try: if not accepting_injections.is_set(): return False # Pad-bound actions run on the dictation pad no matter which # application is focused, so a focus change since the command # was issued must not drop them; only app-bound deliveries are # focus-checked. if action == "delete_last": targets_pad = bool(last_injected["to_pad"]) else: targets_pad = dictate_to_pad_enabled() if not targets_pad and not _focused_app_unchanged(_probe_result(target_probe)): logger.info("Dropping action: focus moved to another application") return False with injection_lock: if not accepting_injections.is_set(): return False # "delete that" follows the segment it removes: the pad # when the last delivered text went there (even if capture # was toggled off since) and the focused application when # it was injected (even if capture was toggled on). Every # other editing command is handled pad-side while capturing # so its shortcuts never leak into whichever application # holds focus. if action == "delete_last": if not action_handler.last_injected_text: return True if last_injected["to_pad"]: # Only the pad's own segment bookkeeping may size # the deletion: a manual edit or a pad "undo" # blurs the boundaries (last_segment is None), and # falling back to the recorded text's length could # erase characters the user typed after dictating. # Refuse rather than misdelete — the tracking is # still cleared so a repeated command cannot retry # the stale length. target = dictation_pad.last_segment if target is None: action_handler.set_last_injected_text("") last_injected["to_pad"] = False return True deleted = dictation_pad.delete_last_chars(len(target)) if deleted: action_handler.set_last_injected_text("") last_injected["to_pad"] = False return True handled_app: bool = action_handler.handle_action(action) return handled_app if targets_pad: handled = bool(dictation_pad.handle_action(action)) if handled and action in ("undo", "redo") and last_injected["to_pad"]: # Pad history moved: retarget "delete that" at the # segment now at the pad's tail. Keep the pad # destination even when the tail is empty — a # redo can restore it. action_handler.set_last_injected_text(dictation_pad.last_segment or "") return handled handled_app = action_handler.handle_action(action) return handled_app finally: with pending_jobs_lock: pending_jobs -= 1 def action_callback_wrapper(action: str) -> Optional[Future]: """Queue a voice-command action behind any pending text jobs. Args: action: Voice-command action from the speech engine. Returns: The queued worker future, or None while the application is quitting. Callers such as tests can wait on it; the speech engine ignores the return value. """ nonlocal pending_jobs if not accepting_injections.is_set(): return None # Every action is bound to the app focused at submit time: # submission does not guarantee immediate execution, so there is # always a focus-change window between the two. The probe runs # off this thread and the worker only waits on it briefly, so # the binding costs nothing on a healthy desktop. # Probing and submitting happen inside the lock so queue order # matches the order these callbacks ran in — a job that saw an # empty queue cannot end up waiting behind one that arrived while # its submission was still in flight. with pending_jobs_lock: pending_jobs += 1 target_probe = _start_focus_probe() try: future: Future = post_processing_executor.submit( _run_action, action, target_probe ) except RuntimeError: # The quit path already shut the worker down. pending_jobs -= 1 return None return future def _reset_last_injected() -> None: """Clear the last-injected buffer on the post-processing worker.""" action_handler.set_last_injected_text("") last_injected["to_pad"] = False def _shutdown_post_processing() -> None: """Stop the post-processing worker for application quit. Clearing the flag makes queued or in-flight jobs drop their results, and pending submissions are cancelled without waiting on a running script — quit must not freeze the tray on the script's own timeout. An injection already in progress is asked to abort so the lock wait stays bounded by a single chunk's subprocess timeout instead of a long transcription's whole budget, and the injector is never stopped mid-inject. """ accepting_injections.clear() text_system.abort_injections() with injection_lock: pass post_processing_executor.shutdown(wait=False, cancel_futures=True) def text_callback_wrapper(text: str) -> Optional[Future]: """Bridge between speech engine text events and the text injector. Called on the recognition thread with each finalised transcription segment. Strips leading and trailing spaces/tabs (but preserves newlines from voice commands), then hands the segment to the single post-processing worker — a pass-through when no script is configured — which applies the separator rules and injects via TextInjector in spoken order. Args: text: Raw transcription segment from the speech engine. Returns: The queued worker future, or None when the segment was dropped before submission (an empty segment, or the application is quitting). Callers such as tests can wait on it; the speech engine ignores the return value. """ nonlocal pending_jobs # Preserve trailing newlines ("new line" / "new paragraph"); only # strip spaces/tabs that whisper sometimes wraps around tokens. text_to_inject = _normalize_segment_text(text) if not text_to_inject or not accepting_injections.is_set(): return None # A backlog or a configured script means this segment can inject # long after it was dictated; bind it to the app it targets now so # it cannot land in whatever the user switched to meanwhile. The # probe runs off this thread: compositor tools could otherwise # stall the consumer of the bounded audio-segment queue for seconds # per segment. script_configured = bool(config_manager.get_str("post_processing", "script_path", "")) with pending_jobs_lock: may_queue = pending_jobs > 0 or script_configured pending_jobs += 1 target_probe = _start_focus_probe() if may_queue else None try: future: Future = post_processing_executor.submit( post_process_and_inject, text_to_inject, target_probe ) except RuntimeError: # The quit path already shut the worker down. pending_jobs -= 1 return None return future def on_state_change(state: RecognitionState) -> None: """Reset the last-injected buffer when a listening session ends. Also commits the just-finished dictation session to the transcription history as a single snippet. With jobs still queued the reset is queued behind them, so a "delete that" action recognised just before the session ended still sees the text it refers to. """ nonlocal session_open, session_worker, latest_snippet_id nonlocal ended_session_epoch, session_started_floor, latest_snippet_epoch nonlocal session_record_fields, ended_session_fields if state in (RecognitionState.IDLE, RecognitionState.ERROR): if state == RecognitionState.IDLE: with pending_jobs_lock: backlog = pending_jobs > 0 if backlog and accepting_injections.is_set(): try: post_processing_executor.submit(_reset_last_injected) except RuntimeError: action_handler.set_last_injected_text("") last_injected["to_pad"] = False else: action_handler.set_last_injected_text("") last_injected["to_pad"] = False with session_lock: session_open = False closing_worker = session_worker session_worker = None # Snapshot before re-reading the engine: the fields belong # to the session being closed, and the live values could # already describe a reconfigure that landed mid-close. closing_fields = session_record_fields or _history_record_fields() session_record_fields = None ended_session_fields = closing_fields ended_session_epoch = transcription_history.epoch # Only segments captured after the last clear() join the # snippet — speech captured before it is gone for good, # while dictation continued across the clear is kept. cleared_at = transcription_history.cleared_at joined = " ".join( text for text, started_at in session_segments if started_at > cleared_at ) session_segments.clear() # Guarded by the epoch observed here: a clear() landing # between this read and the add still refuses the # snippet. The committed snippet's id stays open to late # segments still trickling out of the worker; a session # that produced no text leaves no entry to merge into. latest_snippet_id = transcription_history.add( joined, expected_epoch=ended_session_epoch, duration=( time.monotonic() - session_started_floor if session_started_floor else None ), **closing_fields, ) latest_snippet_epoch = ended_session_epoch if closing_worker is not None: ended_worker_fields[closing_worker] = closing_fields if latest_snippet_id is not None and closing_worker is not None: ended_worker_snippets[closing_worker] = ( latest_snippet_id, ended_session_epoch, ) else: with session_lock: if not session_open: session_open = True leftover_worker = session_worker session_worker = None # Leftover segments belong to the displaced session; # grab its fields before the new session re-captures. leftover_fields = session_record_fields or ended_session_fields if leftover_worker is not None: ended_worker_fields[leftover_worker] = leftover_fields session_started_floor = time.monotonic() session_record_fields = _history_record_fields() # Segments left over by a session that ended without a # closing state commit as their own snippet rather # than leaking into the new session's — still only # those captured after the last clear(). if session_segments: cleared_at = transcription_history.cleared_at # A clear() during the unclosed session bumped the # epoch since the previous close — judge against # the live epoch, not the stale ended one, or # valid post-clear dictation is refused. stray_epoch = transcription_history.epoch latest_snippet_id = transcription_history.add( " ".join( text for text, started_at in session_segments if started_at > cleared_at ), expected_epoch=stray_epoch, **leftover_fields, ) latest_snippet_epoch = stray_epoch if latest_snippet_id is not None and leftover_worker is not None: ended_worker_snippets[leftover_worker] = ( latest_snippet_id, stray_epoch, ) session_segments.clear() # Connect speech recognition to text injection and action handling. # Segments reach history with their capture-start time so a mid- # session clear() can separate pre-clear speech from new dictation; # injection keeps the plain text callback. speech_engine.register_segment_callback(record_history_segment) speech_engine.register_text_callback(text_callback_wrapper) speech_engine.register_action_callback(action_callback_wrapper) speech_engine.register_state_callback(on_state_change) # Initialize and start the system tray indicator indicator = tray_indicator.TrayIndicator( speech_engine=speech_engine, text_injector=text_system, transcription_history=transcription_history, on_quit=_shutdown_post_processing, dictation_pad=dictation_pad, ) # Start the GTK main loop indicator.run() except Exception as e: logger.error(f"Failed to initialize Voculinux: {e}") logger.error("Please check the logs above for more details") sys.exit(1) if __name__ == "__main__": main()