Files
voculinux/src/vocalinux/main.py
T
soubarnak ea7be5f111 feat(identity): rename user-visible identity to voculinux with one-time legacy import
The fork takes its own name everywhere a user can see it: binaries
(voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus
namespace (com.voculinux.Voculinux, no alias for the old name),
config/data/cache dirs, single-instance lock, IBus engine identity,
tray icons, resource prefixes, VOCULINUX_* environment names, and the
update-checker user agent. The internal Python module stays vocalinux
and the distribution metadata keeps the upstream URLs as provenance.

On first run a previous Vocalinux install's config.json, custom
dictionary, and portal restore token are copied once into the new
locations (new config always wins, no merge, no write-back); models
re-resolve through the pinned-checksum download path. An offline
naming-sweep test proves no old-name strings remain on user-visible
surfaces outside the allowlist.

Refs #18
2026-11-06 01:29:39 +05:30

1394 lines
64 KiB
Python

#!/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()