Files
voculinux/src/vocalinux/ui/tray_indicator.py
T
soubarnak d7a4b8c21e feat(void): add xbps template for the voculinux app
Ticket xavierk/voculinux#27, built on #18 (rename) plus the #25/#26
dependency templates. The template builds the app from the canonical
checksum-verified release sdist, depends on Void's interpreter, GTK,
desktop bindings and packaged Python libraries, and installs the
renamed desktop entry, icons and a packaged-install marker. No speech
models, no vendored wheels, no pip at build or install time.

Packaged installs keep background update checks as a signal, but every
update affordance presents the package-manager path
(src/vocalinux/utils/packaging.py). Also corrects the Void distro
inventory (Ayatana provider name; ydotool marked absent) and regens
the installer map.
2026-11-06 03:03:33 +05:30

2008 lines
86 KiB
Python

"""
System tray indicator module for Voculinux.
This module provides a system tray indicator for controlling the speech
recognition process and displaying its status.
"""
import logging
import os
import signal
import threading
import time
from functools import partial
from typing import Any, Callable, Optional, Sequence, cast
import gi
from ..utils.host_process import host_env, running_from_appimage
# Import GTK
gi.require_version("Gtk", "3.0")
# Prefer libayatana-appindicator-glib: libayatana-appindicator (GTK 3) now
# logs "libayatana-appindicator is deprecated" at startup and points here.
# The glib library takes a GMenuModel instead of a Gtk.Menu, which
# tray_menu_model bridges. The AppImage skips it: its bundled GLib is older
# than the library needs and it does not ship the library, so a host copy on
# GI's default search path would load against the bundle's GLib.
# Otherwise prefer Ayatana AppIndicator: it is the actively maintained fork
# and the one most distros ship for KDE/SNI compatibility. The original
# Canonical AppIndicator3 (unmaintained since ~2013) can be present alongside
# it (e.g. Fedora's libappindicator-gtk3) but fails to register a
# StatusNotifierItem on modern KDE Plasma, leaving the tray icon invisible
# with no error logged.
INDICATOR_USES_GMENU = False
if not running_from_appimage():
try:
gi.require_version("AyatanaAppIndicatorGlib", "2.0")
from gi.repository import AyatanaAppIndicatorGlib as AppIndicator
INDICATOR_USES_GMENU = True
except (ImportError, ValueError):
pass
if not INDICATOR_USES_GMENU:
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator
except (ImportError, ValueError):
try:
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 as AppIndicator
except (ImportError, ValueError):
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3 as AppIndicator
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
from ..app_identity import APP_ID as CENTRAL_APP_ID
from ..app_identity import (
ICON_ACTIVE,
ICON_DEFAULT,
ICON_PROCESSING,
TRAY_TITLE,
)
# Import local modules - Use protocols to avoid circular imports
from ..auto_pause_monitor import DEFAULT_POLL_INTERVAL_SECONDS, AutoPauseMonitor
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
from ..dbus_service import VocalinuxDBusService
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager
from ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive
from ..speech_recognition.diarization import (
TranscriptBlock,
ffmpeg_available,
transcribe_audio_file,
)
from ..suspend_handler import SuspendHandler
from ..utils.packaging import (
is_packaged_install,
update_menu_label,
update_menu_tooltip,
update_notification_body,
)
from ..utils.resource_manager import ResourceManager
from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo
from ..utils.update_monitor import UpdateMonitor
from ..utils.whispercpp_model_info import TDRZ_MODEL, WHISPERCPP_MODEL_INFO, is_model_downloaded
from . import notifications
from .config_manager import get_shared_config_manager
from .keyboard_backends import (
DEFAULT_SHORTCUT,
DEFAULT_SHORTCUT_MODE,
SHORTCUT_MODES,
shortcut_gestures_collide,
)
from .keyboard_shortcuts import KeyboardShortcutManager
from .settings_dialog import (
ModelDownloadDialog,
SettingsDialog,
_apply_settings_lock,
_live_settings_lock,
recommended_model_for_engine,
)
from .transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
from .tray_menu_model import GMenuIndicator
logger = logging.getLogger(__name__)
# Flatpak StatusNotifier host resolves exported <app-id>-* icons, not /app paths.
FLATPAK_ID = os.environ.get("FLATPAK_ID")
APP_ID = FLATPAK_ID or CENTRAL_APP_ID
# Initialize resource manager
_resource_manager = ResourceManager()
ICON_DIR = _resource_manager.icons_dir
# How often a running model download refreshes its notification.
_DOWNLOAD_NOTIFY_INTERVAL_SECONDS = 5.0
def _idle_once(func, *args):
"""Schedule func on the GTK main loop for exactly one run.
GLib.idle_add repeats a callback for as long as it returns a truthy value,
and the notification helpers return handles and success flags, so they must
not be handed to idle_add directly.
"""
def _call():
func(*args)
return False
GLib.idle_add(_call)
# Bundled icon file names (paths under ICON_DIR).
DEFAULT_ICON = ICON_DEFAULT
ACTIVE_ICON = ICON_ACTIVE
PROCESSING_ICON = ICON_PROCESSING
def _themed_icon_names() -> dict:
"""Icon theme names for the current runtime (host-exported names in Flatpak)."""
if FLATPAK_ID:
return {
"default": f"{FLATPAK_ID}-microphone-off",
"active": f"{FLATPAK_ID}-microphone",
"processing": f"{FLATPAK_ID}-microphone-process",
}
return {
"default": DEFAULT_ICON,
"active": ACTIVE_ICON,
"processing": PROCESSING_ICON,
}
# Maximum characters shown for a history snippet label before truncation.
_HISTORY_LABEL_MAX_CHARS = 50
# /dev/input settle-detection tuning (used after resume)
_INPUT_SETTLE_SECONDS = 2
_INPUT_MONITOR_CAP_SECONDS = 10
_FALLBACK_KEYBOARD_RESTART_SECONDS = 6
class TrayIndicator:
"""
System tray indicator for Voculinux.
This class provides a system tray icon with a menu for controlling
the speech recognition process.
"""
# Tests build TrayIndicator stubs with __new__ that skip __init__; keep a
# class-level default so teardown paths like _quit() still find the list.
_language_shortcut_managers: list = []
_indicator_title: str = TRAY_TITLE
#: Same for the push-to-talk owner slot: a __new__-built stub must answer
#: "no binding owns the live session" instead of raising on release paths.
_ptt_owner: Optional["KeyboardShortcutManager"] = None
#: Serializes push-to-talk press/release handling. Press and release
#: callbacks run on separate backend threads, so a release landing while
#: start_recognition is still running must not be lost (#805).
_ptt_lock = threading.RLock()
def __init__(
self,
speech_engine: SpeechRecognitionManagerProtocol,
text_injector: TextInjectorProtocol,
transcription_history: Optional[TranscriptionHistory] = None,
on_quit: Optional[Callable[[], None]] = None,
dictation_pad: Optional[Any] = None,
) -> None:
"""
Initialize the system tray indicator.
Args:
speech_engine: The speech recognition manager instance
text_injector: The text injector instance
transcription_history: Optional in-memory store of recent dictation
snippets. When provided, a "Recent Snippets" submenu is shown.
on_quit: Optional hook run during _quit before the text injector
is stopped (drains the post-processing worker so a queued or
in-flight segment cannot inject into a torn-down app)
dictation_pad: Optional in-app Dictation Pad window the tray menu
can open (the Wayland-safe dictation fallback, #726)
"""
self.speech_engine = speech_engine
self.text_injector = text_injector
self.transcription_history = transcription_history
self._on_quit = on_quit
self.dictation_pad = dictation_pad
# Shared with main() and the settings dialog: separate instances would
# overwrite each other's saves with stale in-memory copies.
self.config_manager = get_shared_config_manager()
# Set once by _on_dbus_registration_failed and never cleared: the
# service does not retry, so every later reconfigure (mode change,
# settings toggle, resume) must keep honoring the fallback rather
# than reapplying a disable_internal_hotkey setting D-Bus cannot serve.
self._external_activation_unavailable = False
self._history_menu_item = None
# Refresh the history submenu whenever the history changes. The change
# callback fires on the recognition thread, so marshal onto the GTK
# main thread before touching widgets.
if self.transcription_history is not None:
self.transcription_history.set_change_callback(
lambda: GLib.idle_add(self._refresh_history_menu)
)
# Get configured shortcut and mode from config
shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE)
# Initialize keyboard shortcut manager with configured shortcut and mode
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
self.shortcut_manager.set_pass_shortcut_to_apps(
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
)
# One listener per bound language shortcut (#805); rebuilt by
# _setup_language_shortcuts alongside the main one.
self._language_shortcut_managers: list[KeyboardShortcutManager] = []
# A settings edit landed while a dictation session was live; the
# listener rebuild is deferred to the next IDLE transition (#805).
self._language_shortcuts_refresh_pending = False
# The binding whose press started the live push-to-talk session, so an
# unrelated key's release cannot end it (#805).
self._ptt_owner: Optional[KeyboardShortcutManager] = None
# Serializes the press/release pair across backend threads, so a
# release landing while start_recognition runs cannot be lost.
self._ptt_lock = threading.RLock()
# Ensure icon directory exists
os.makedirs(ICON_DIR, exist_ok=True)
# Set up icon file paths using resource manager
self.icon_paths = {
"default": _resource_manager.get_icon_path(DEFAULT_ICON),
"active": _resource_manager.get_icon_path(ACTIVE_ICON),
"processing": _resource_manager.get_icon_path(PROCESSING_ICON),
}
self.icon_names = _themed_icon_names()
# Prefer absolute icon paths outside Flatpak. StatusNotifier hosts often
# resolve IconName via the system hicolor theme and ignore IconThemePath,
# so a stale ~/.local/share/icons copy of voculinux-microphone-off.svg
# (the old red placeholder) would win over the AppImage's bundled icons.
self._icon_keys = {
"default": (self.icon_names["default"] if FLATPAK_ID else self.icon_paths["default"]),
"active": (self.icon_names["active"] if FLATPAK_ID else self.icon_paths["active"]),
"processing": (
self.icon_names["processing"] if FLATPAK_ID else self.icon_paths["processing"]
),
}
# Last IconName applied this session. StatusNotifier hosts skip a
# redraw when the same path is reused; _set_indicator_icon nudges
# IconThemePath so gray→red→gray→red actually turns red again.
self._last_icon_key: Optional[str] = None
self._icon_theme_nudge = False
self._indicator_title = TRAY_TITLE
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
# Offer the download instead of only telling the user a model is missing
self._model_download_active = False
if hasattr(self.speech_engine, "set_model_missing_handler"):
self.speech_engine.set_model_missing_handler(self._offer_recommended_model)
# Floating on-screen indicator (glow while listening); gated by config.
# Lazy import keeps tray import light for tests that mock gi.
from .dictation_overlay import DictationOverlay
# Quit from the dock closes the overlay toplevel; treat it as Quit (#1028).
self.overlay = DictationOverlay(
enabled=self.config_manager.is_overlay_enabled(),
on_close_request=self._quit,
)
self.speech_engine.register_audio_level_callback(self.overlay.set_audio_level)
# Initialize the icon files and validate resources
self._init_icons()
self._validate_resources()
# Must exist before _init_indicator: tests run idle_add synchronously.
self._pending_update: Optional[ReleaseInfo] = None
self._update_menu_item = None
self._settings_dialog: Optional[SettingsDialog] = None
self._logging_dialog: Optional[Gtk.Dialog] = None
# Initialize the indicator (in the GTK main thread)
GLib.idle_add(self._init_indicator)
self._system_suspended = False
self._suspend_handler = SuspendHandler(
on_suspend=self._on_system_suspend,
on_resume=self._on_system_resume,
)
# Auto-pause: unload model while configured games/apps are running
self._auto_pause_monitor = AutoPauseMonitor(
get_config=self._get_auto_pause_config,
on_pause=self._on_auto_pause,
on_resume=self._on_auto_resume,
)
self._auto_pause_monitor.start()
# Idle keep-alive: unload model after inactivity (battery / Optimus)
self._model_keepalive = ModelKeepAlive(
get_config=self._get_model_keepalive_config,
on_idle_unload=self._on_keepalive_idle_unload,
is_safe_to_unload=self._is_safe_for_keepalive_unload,
)
self._model_keepalive.start()
self._model_keepalive.bump()
# Background GitHub update checks (tray menu + optional notification)
self._update_monitor = UpdateMonitor(
get_enabled=self._update_checks_enabled,
on_result=self._on_update_check_result,
)
self._update_monitor.start()
# Set up keyboard shortcuts with mode support
self._setup_keyboard_shortcuts()
# Register the session-bus service so external triggers (e.g. a KDE
# Plasma global shortcut running `voculinux --toggle`) can control this
# running instance. Handlers marshal onto the GTK main thread.
self._dbus_service = VocalinuxDBusService(
on_toggle=self._toggle_recognition,
on_start=self._external_start,
on_stop=self._external_stop,
on_registration_failed=self._on_dbus_registration_failed,
)
def _external_activation_active(self) -> bool:
"""Whether the internal listener should stay off right now.
True only when the saved setting asks for it *and* the D-Bus service
has not already failed to register this run. The service does not
retry, so once it has failed, this stays False for the rest of the
process regardless of the saved setting — every later reconfigure
(mode change, settings toggle, resume) must keep using the fallback
instead of re-disabling the only working activation path.
"""
if self._external_activation_unavailable:
return False
return self.config_manager.get_bool("shortcuts", "disable_internal_hotkey", False)
def _on_dbus_registration_failed(self) -> None:
"""Fall back to the internal listener if external activation cannot work.
Runs when the D-Bus service could not claim its bus name or register
its object (no session bus, name already owned, etc.). If the internal
evdev/pynput listener is also disabled, the user would otherwise be
left with no way to start or stop dictation at all.
"""
if not self.config_manager.get_bool("shortcuts", "disable_internal_hotkey", False):
return
logger.error(
"D-Bus activation unavailable and the internal listener is disabled; "
"falling back to the internal listener"
)
notifications.notify(
"External activation unavailable",
"The D-Bus service could not start, so Voculinux is using the "
"internal keyboard shortcut instead. Check Settings -> Shortcuts.",
"dialog-warning",
)
self._external_activation_unavailable = True
self._setup_keyboard_shortcuts()
def _setup_keyboard_shortcuts(self):
"""Set up keyboard shortcuts based on configured mode."""
# Reconfiguring (e.g. live-toggling external activation) tears down the
# release callback below. A push-to-talk session held at that moment
# would then never see its release, leaving recognition and the
# microphone running with no way back except another control surface.
if self.speech_engine.state != RecognitionState.IDLE:
logger.info("Stopping active recognition before reconfiguring shortcuts")
self._stop_recognition()
# Stop existing shortcut manager if running
if self.shortcut_manager.active:
logger.info("Stopping existing shortcut manager before reconfiguration")
self.shortcut_manager.stop()
# Clear any existing callbacks to prevent duplicate triggers
self.shortcut_manager.register_toggle_callback(None)
self.shortcut_manager.register_press_callback(None)
self.shortcut_manager.register_release_callback(None)
# External-activation mode: skip the internal evdev/pynput listener
# entirely so no /dev/input access is required. Activation then comes
# in over D-Bus (see VocalinuxDBusService).
if self._external_activation_active():
logger.info("Internal hotkey listener disabled (external activation via D-Bus)")
# The whole internal listener family is off; per-language
# listeners must not keep firing either.
self._stop_language_shortcut_managers()
return
# Get configured mode from config
mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE)
logger.info(f"Setting up keyboard shortcuts with mode: {mode}")
if mode == "toggle":
# Register toggle callback for double-tap mode
self.shortcut_manager.register_toggle_callback(self._toggle_recognition)
elif mode == "push_to_talk":
# Register press/release callbacks for push-to-talk mode
self.shortcut_manager.register_press_callback(self._start_recognition)
self.shortcut_manager.register_release_callback(self._release_main_push_to_talk)
# Start the keyboard shortcut manager
self.shortcut_manager.start()
self._setup_language_shortcuts()
def update_shortcut_passthrough(self, enabled: bool) -> None:
"""Live-update main and language shortcuts without stopping dictation."""
self.shortcut_manager.set_pass_shortcut_to_apps(enabled)
for manager in self._language_shortcut_managers:
manager.set_pass_shortcut_to_apps(enabled)
def _setup_language_shortcuts(self) -> None:
"""(Re)build a listener per configured language shortcut (#805).
Each entry gets its own KeyboardShortcutManager; on evdev the
backends all share one process-wide device layer, so parallel
listeners observe the same keyboards without competing grabs. A
binding whose gesture collides with the main shortcut or an earlier
language binding is skipped: the same gesture cannot fire both.
"""
# Same stop-first protection as _setup_keyboard_shortcuts: rebuilding
# below removes the release callback a held push-to-talk key needs to
# end its session, so the hold must be ended before it can be lost.
if self.speech_engine.state != RecognitionState.IDLE:
logger.info("Stopping active recognition before rebuilding language shortcuts")
self._stop_recognition()
self._stop_language_shortcut_managers()
mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE)
if mode not in SHORTCUT_MODES:
# A hand-edited mode must not take down the whole shortcut setup
# via a backend constructor's ValueError.
logger.warning(f"Unknown shortcut mode {mode!r} for language shortcuts")
mode = DEFAULT_SHORTCUT_MODE
main_shortcut = (
self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
.strip()
.lower()
)
# Collisions are judged on the gestures the backends match, not the
# strings: "ctrl+alt+r" and "alt+ctrl+r" name the same key press, and
# "left_ctrl+left_ctrl" fires inside "ctrl+ctrl"'s key set.
bound = [main_shortcut]
for entry in self.config_manager.get_language_shortcuts():
shortcut = entry["shortcut"]
language = entry["language"]
if any(shortcut_gestures_collide(shortcut, other) for other in bound):
logger.warning(
f"Skipping language shortcut {shortcut!r} for {language}: "
"it shares its gesture with another binding"
)
continue
bound.append(shortcut)
try:
manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
manager.set_pass_shortcut_to_apps(
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
)
except ValueError as exc:
logger.warning(f"Skipping language shortcut {shortcut!r} for {language}: {exc}")
continue
if mode == "toggle":
manager.register_toggle_callback(
partial(self._toggle_recognition_in_language, language)
)
else:
manager.register_press_callback(
partial(self._start_recognition_in_language, language, manager)
)
manager.register_release_callback(
partial(self._release_language_push_to_talk, manager)
)
manager.start()
self._language_shortcut_managers.append(manager)
logger.info(f"Language shortcut {shortcut} -> {language} armed ({mode} mode)")
def _apply_history_settings(self) -> None:
"""Push the saved history preferences onto the live store (#805).
Toggling history off must stop retention immediately — not after a
restart — and the snippets-keep limit applies to what's already held.
"""
if self.transcription_history is None:
return
enabled = self.config_manager.get_bool("history", "enabled", True)
max_items = self.config_manager.get_int("history", "max_items", DEFAULT_MAX_ITEMS)
persist = self.config_manager.get_bool("history", "persist", False)
self.transcription_history.set_max_items(max_items)
self.transcription_history.set_enabled(enabled)
self.transcription_history.set_persist(persist)
def _stop_language_shortcut_managers(self) -> None:
"""Stop every per-language listener and drop the managers."""
for manager in self._language_shortcut_managers:
try:
manager.stop()
except Exception:
logger.exception("Failed to stop a language shortcut manager")
self._language_shortcut_managers = []
def refresh_language_shortcuts(self) -> None:
"""Rebuild the per-language listeners after a settings change (#805).
While a dictation session is live the rebuild is deferred to the
next IDLE transition instead: rebuilding stops recognition first,
and a keystroke-level settings edit must never cut an utterance off.
"""
if self._external_activation_active():
# The internal listener family is off; a binding edit must not
# arm language listeners behind the external-activation setting.
self._stop_language_shortcut_managers()
return
if self.speech_engine.state != RecognitionState.IDLE:
self._language_shortcuts_refresh_pending = True
return
self._setup_language_shortcuts()
def _init_icons(self):
"""Initialize the icon files for the tray indicator."""
# Ensure icon directory exists
_resource_manager.ensure_directories_exist()
def _validate_resources(self):
"""Validate that required resources are available."""
validation_results = _resource_manager.validate_resources()
if not validation_results["resources_dir_exists"]:
logger.warning("Resources directory not found")
if validation_results["missing_icons"]:
logger.warning(f"Missing icon files: {validation_results['missing_icons']}")
if validation_results["missing_sounds"]:
logger.warning(f"Missing sound files: {validation_results['missing_sounds']}")
# Log successful validation
if (
validation_results["resources_dir_exists"]
and not validation_results["missing_icons"]
and not validation_results["missing_sounds"]
):
logger.info("All required resources validated successfully")
def _init_indicator(self):
"""Initialize the system tray indicator."""
logger.info("Initializing system tray indicator")
# Log the icon directory path
logger.info(f"Using icon directory: {ICON_DIR}")
logger.info(f"Icon directory exists: {os.path.exists(ICON_DIR)}")
# List available icon files and check if they exist
if os.path.exists(ICON_DIR):
icon_files = os.listdir(ICON_DIR)
logger.info(f"Available icon files: {icon_files}")
for name, path in self.icon_paths.items():
exists = os.path.exists(path)
logger.info(f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}")
initial_icon = self._icon_keys["default"]
try:
if FLATPAK_ID:
indicator = AppIndicator.Indicator.new(
APP_ID,
initial_icon,
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
)
else:
indicator = AppIndicator.Indicator.new_with_path(
APP_ID,
initial_icon,
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
indicator.set_icon_theme_path(ICON_DIR)
self.indicator = GMenuIndicator(indicator, Gio) if INDICATOR_USES_GMENU else indicator
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
self.indicator.set_title(TRAY_TITLE)
self._indicator_title = TRAY_TITLE
except Exception as e:
logger.error(f"Failed to create AppIndicator: {e}")
GLib.idle_add(self._show_appindicator_error_dialog, str(e))
return False
if not self._check_status_notifier_watcher():
logger.warning(
"No StatusNotifierWatcher on D-Bus session bus; tray icon may not appear. "
"On GNOME, install gnome-shell-extension-appindicator."
)
if self.config_manager.get_bool("ui", "show_missing_tray_warning", True):
GLib.idle_add(self._show_missing_watcher_dialog)
# Create the menu
self.menu = Gtk.Menu()
# Add menu items
self._add_menu_item("Start Voice Typing", self._on_start_clicked)
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
self._cancel_menu_item = self._add_menu_item(
"Cancel Transcription", self._on_cancel_transcription_clicked
)
self._cancel_menu_item.set_no_show_all(True)
self._cancel_menu_item.hide()
self._add_menu_item("Transcribe Audio File\u2026", self._on_transcribe_file_clicked)
self._add_menu_separator()
# Recent snippets submenu (only when history is enabled)
if self.transcription_history is not None and self.transcription_history.enabled:
self._history_menu_item = Gtk.MenuItem.new_with_label("Recent Snippets")
self.menu.append(self._history_menu_item)
self._refresh_history_menu()
self._add_menu_separator()
if self.dictation_pad is not None:
self._add_menu_item("Dictation Pad", self._on_dictation_pad_clicked)
self._add_menu_item("Settings", self._on_settings_clicked)
self._add_menu_item("View Logs", self._on_logs_clicked)
self._gateway_stop_menu_item = self._add_menu_item(
"Stop local Gateway", self._on_stop_local_gateway_clicked
)
self._gateway_stop_menu_item.set_no_show_all(True)
self._gateway_stop_menu_item.hide()
self._add_menu_separator()
# Hidden until a background check finds a newer release.
self._update_menu_item = self._add_menu_item(
"Update Available…", self._on_update_available_clicked
)
self._update_menu_item.set_no_show_all(True)
self._update_menu_item.hide()
self._add_menu_item("Quit", self._on_quit_clicked)
# Set the indicator menu
self.indicator.set_menu(self.menu)
# Show the menu
self.menu.show_all()
# show_all() would re-show the update item; keep it hidden until needed.
if self._pending_update is None:
self._update_menu_item.hide()
else:
self._show_update_menu_item(self._pending_update)
self._gateway_stop_menu_item.hide()
self._sync_indicator_menu()
self._gateway_manager = get_gateway_embed_manager()
self._gateway_manager.add_listener(self._on_gateway_status_for_tray)
# Runtime detect + leftover compose probe (Quit does not stop compose).
self._gateway_manager.begin_runtime_detection()
self._sync_gateway_stop_menu(self._gateway_manager.status)
# Update the UI based on the initial state
self._update_ui(RecognitionState.IDLE)
return False # Remove idle callback
@staticmethod
def _check_status_notifier_watcher() -> bool:
try:
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 _show_missing_watcher_dialog(self):
dialog = Gtk.MessageDialog(
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.OK,
text="System tray icon may not appear",
)
dialog.format_secondary_text(
"Voculinux could not detect AppIndicator support in your desktop environment.\n"
"\n"
"If you are using GNOME Shell, install the AppIndicator extension:\n"
" sudo apt install gnome-shell-extension-appindicator\n"
"\n"
"Then log out and back in (or press Alt+F2, type 'r', press Enter).\n"
"\n"
"Keyboard shortcuts will still work even without the tray icon."
)
dont_show_again = Gtk.CheckButton(label="Don't show again")
dont_show_again.set_margin_top(8)
dialog.get_message_area().pack_start(dont_show_again, False, False, 0)
def on_response(d, _response):
if dont_show_again.get_active():
self.config_manager.set("ui", "show_missing_tray_warning", False)
self.config_manager.save_settings()
d.destroy()
dialog.connect("response", on_response)
dialog.show_all()
return False
def _show_appindicator_error_dialog(self, error_detail: str):
dialog = Gtk.MessageDialog(
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Failed to initialize system tray",
)
dialog.format_secondary_text(
f"AppIndicator could not be started:\n{error_detail}\n"
"\n"
"Make sure the required packages are installed:\n"
" sudo apt install gir1.2-ayatanaappindicator3-0.1\n"
"\n"
"On GNOME Shell, you also need:\n"
" sudo apt install gnome-shell-extension-appindicator"
)
dialog.connect("response", lambda d, _: d.destroy())
dialog.show()
return False
def _toggle_recognition(self) -> None:
"""Toggle the recognition state between IDLE and LISTENING."""
if self._cancel_if_transcribing():
return
with self._ptt_lock:
self._ptt_owner = None
logger.debug(f"Toggle requested in state {self.speech_engine.state.name}")
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition()
else:
self.speech_engine.stop_recognition()
def _start_recognition(self) -> None:
"""Start voice recognition (for push-to-talk mode)."""
if self._cancel_if_transcribing():
return
self._push_to_talk_press(
self.shortcut_manager,
lambda: self.speech_engine.start_recognition(mode="push_to_talk"),
)
def _cancel_if_transcribing(self) -> bool:
"""Cancel a running transcription; the recording hotkey doubles as cancel (#991)."""
if self.speech_engine.state != RecognitionState.PROCESSING:
return False
is_transcribing = getattr(self.speech_engine, "is_transcribing", None)
if not callable(is_transcribing) or is_transcribing() is not True:
return False
cancel = getattr(self.speech_engine, "cancel_transcription", None)
return callable(cancel) and cancel() is True
def _push_to_talk_press(
self, manager: Optional[KeyboardShortcutManager], start: Callable[[], bool]
) -> None:
"""Push-to-talk press shared by the main and per-language bindings (#805).
The release runs on another backend thread and used to be dropped when
it landed between LISTENING and the owner assignment, leaving
dictation running with the key already up. Holding the lock through
the whole start makes the release wait instead: it then finds the
owner and ends the session normally.
"""
with self._ptt_lock:
if self.speech_engine.state == RecognitionState.IDLE and start():
self._ptt_owner = manager
def _push_to_talk_release(self, manager: KeyboardShortcutManager) -> None:
"""Push-to-talk release: end the session only when this binding owns it."""
with self._ptt_lock:
if self._ptt_owner is manager:
self._stop_recognition()
def _release_main_push_to_talk(self) -> None:
"""End a push-to-talk session only when the main binding started it (#805)."""
self._push_to_talk_release(self.shortcut_manager)
def _release_language_push_to_talk(self, manager: KeyboardShortcutManager) -> None:
"""End a push-to-talk session only when this binding started it (#805).
Sharing one stop callback across listeners let an unrelated binding's
release end a session it never started.
"""
self._push_to_talk_release(manager)
def _toggle_recognition_in_language(self, language: str) -> None:
"""Toggle-style trigger for a per-language shortcut (#805).
While idle it starts a one-shot dictation in ``language``; while
dictating it stops, matching the main toggle's behaviour regardless
of which language the current utterance is in.
"""
if self._cancel_if_transcribing():
return
with self._ptt_lock:
self._ptt_owner = None
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition_with_language(language)
else:
self.speech_engine.stop_recognition()
def _start_recognition_in_language(
self, language: str, manager: Optional[KeyboardShortcutManager] = None
) -> None:
"""Push-to-talk start for a per-language shortcut (#805)."""
if self._cancel_if_transcribing():
return
self._push_to_talk_press(
manager,
lambda: self.speech_engine.start_recognition_with_language(
language, mode="push_to_talk"
),
)
def _external_start(self) -> None:
"""Start recognition for an external (D-Bus) trigger.
Uses normal start semantics — not push-to-talk — so a single
`voculinux --start` transcribes immediately/with silence detection
rather than deferring until a Stop.
"""
with self._ptt_lock:
self._ptt_owner = None
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition()
def _external_stop(self) -> None:
"""Stop recognition for an external (D-Bus) trigger."""
with self._ptt_lock:
self._ptt_owner = None
if self.speech_engine.state != RecognitionState.IDLE:
self.speech_engine.stop_recognition()
def _stop_recognition(self) -> None:
"""Stop voice recognition (for push-to-talk mode)."""
with self._ptt_lock:
self._ptt_owner = None
if self.speech_engine.state != RecognitionState.IDLE:
self.speech_engine.stop_recognition()
def _add_menu_item(self, label: str, callback: Callable):
"""
Add a menu item to the indicator menu.
Args:
label: The label for the menu item
callback: The callback function to call when the item is clicked
"""
item = Gtk.MenuItem.new_with_label(label)
item.connect("activate", callback)
self.menu.append(item)
return item
def _add_menu_separator(self):
"""Add a separator to the indicator menu."""
separator = Gtk.SeparatorMenuItem()
self.menu.append(separator)
def _sync_indicator_menu(self) -> None:
"""Re-export the Gtk menu after a change when the indicator mirrors it."""
indicator = getattr(self, "indicator", None)
if isinstance(indicator, GMenuIndicator):
indicator.refresh_menu()
def _on_recognition_state_changed(self, state: RecognitionState):
"""
Handle changes in the speech recognition state.
Args:
state: The new recognition state
"""
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
if state == RecognitionState.IDLE:
keepalive.bump()
else:
keepalive.cancel()
# Publish dictation state on D-Bus for status bars and scripts
# (ticket #21). The service may not exist yet when the first state
# callback fires during __init__, hence the guarded lookup.
from ..dbus_service import state_from_recognition
dbus_service = getattr(self, "_dbus_service", None)
if dbus_service is not None:
try:
dbus_service.set_state(state_from_recognition(state))
except Exception:
logger.debug("Failed to publish dictation state", exc_info=True)
# Update the UI in the GTK main thread
GLib.idle_add(self._update_ui, state)
def _offer_recommended_model(self, engine: str) -> bool:
"""Offer the recommended model when dictation finds none installed.
Args:
engine: The engine that has no model on disk.
Returns:
True when the user was told what to do, False to let the caller
fall back to its own notification.
"""
if self._model_download_active:
return True
language = self.config_manager.get_str("speech_recognition", "language", "auto")
try:
recommendation = recommended_model_for_engine(engine, language)
except Exception as e:
logger.error(f"Could not work out a recommended model for {engine}: {e}")
return False
if recommendation is None:
# Remote API transcribes server-side; there is nothing to download.
return False
# Dictation can be triggered from the shortcut thread, so hand the
# notification to the main loop that owns the UI.
GLib.idle_add(self._show_model_offer, engine, recommendation)
return True
def _show_model_offer(self, engine: str, recommendation) -> bool:
"""Show the offer notification. Runs on the main loop."""
action = None
if notifications.supports_actions():
action = (
f"Download {recommendation.display_name} ({recommendation.size_label})",
lambda: self._download_recommended_model(engine, recommendation),
)
if action is not None:
body = (
f"{recommendation.display_name} ({recommendation.size_label}) is recommended "
"for your system. Download it from this notification, or pick another one "
"in Settings."
)
else:
# No action button on this server, so pointing at one would strand
# the user; send them where they can actually do something.
body = (
f"{recommendation.display_name} ({recommendation.size_label}) is recommended "
"for your system. Open Settings to download a speech recognition model."
)
notifications.notify("No Speech Model", body, "dialog-warning", action=action)
return False
def _download_recommended_model(self, engine: str, recommendation) -> None:
"""Start downloading the offered model without blocking the main loop.
Runs on the main loop: libnotify dispatches the action callback there.
"""
if self._model_download_active:
return
if not self.speech_engine.try_begin_download():
# Settings is already downloading a model. A second download would
# fight it over the engine's single progress callback and leave
# whichever finished last as the configured model.
notifications.notify(
"Download already in progress",
"Another speech model is being downloaded. Wait for it to finish, "
"then try again.",
"dialog-information",
)
return
self._model_download_active = True
# libnotify and GObject are not thread-safe, so the notification is
# created here and only its handle travels to the worker, which routes
# every later update back through _idle_once.
progress = notifications.notify(
"Downloading speech model",
f"{recommendation.display_name} ({recommendation.size_label}) — starting...",
"folder-download",
)
threading.Thread(
target=self._run_recommended_model_download,
args=(engine, recommendation, progress),
daemon=True,
name="model-download",
).start()
def _run_recommended_model_download(self, engine: str, recommendation, progress) -> None:
"""Download the offered model, then report how it went."""
last_notified = 0.0
def on_progress(fraction: float, speed_mbps: float, status: str) -> None:
nonlocal last_notified
now = time.monotonic()
if now - last_notified < _DOWNLOAD_NOTIFY_INTERVAL_SECONDS:
return
last_notified = now
_idle_once(
notifications.update,
progress,
"Downloading speech model",
f"{recommendation.display_name}: {status}",
"folder-download",
)
try:
self.speech_engine.set_download_progress_callback(on_progress)
try:
# The download+load is long; holding _apply_settings_lock across
# it would freeze every GTK apply (e.g. Test Dictation). The
# engine serializes reconfigures internally, so the lock is
# only needed around the verify+save: an apply landing after
# the load must keep its own selection and its receipt.
self.speech_engine.reconfigure(
engine=engine,
model_size=recommendation.model_id,
force_download=True,
# A first-run recommendation often matches the current
# selection; force re-init so it still fetches the model.
force_reinit=True,
)
with _apply_settings_lock:
if (
getattr(self.speech_engine, "engine", None) != engine
or getattr(self.speech_engine, "model_size", None)
!= recommendation.model_id
):
raise RuntimeError(
"another speech model was selected before this download completed"
)
paused = getattr(self.speech_engine, "is_auto_paused", False) is True
if not self.speech_engine.model_ready and not paused:
raise RuntimeError("the engine did not load the model after downloading it")
# Auto-pause can unload the verified model before this worker
# returns. Keep the accepted selection for its deferred resume.
with _live_settings_lock:
self.config_manager.set_model_size_for_engine(
engine, recommendation.model_id
)
self.config_manager.save_config()
if paused:
title = "Speech model downloaded"
body = (
f"{recommendation.display_name} is installed. "
"Dictation will be available when Voculinux resumes."
)
else:
shortcut = self.config_manager.get_str(
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
)
title = "Speech model ready"
body = (
f"{recommendation.display_name} is installed. "
f"Press {shortcut} to dictate."
)
finally:
self.speech_engine.set_download_progress_callback(None)
_idle_once(notifications.close, progress)
_idle_once(
notifications.notify,
title,
body,
"emblem-ok",
)
except Exception as e:
logger.error(f"Could not download {recommendation.model_id}: {e}", exc_info=True)
_idle_once(notifications.close, progress)
_idle_once(
notifications.notify,
"Model download failed",
f"{recommendation.display_name} was not installed: {e}. "
"Check your connection and try again from Settings.",
"dialog-error",
)
finally:
self.speech_engine.end_download()
self._model_download_active = False
def _on_transcribe_file_clicked(self, widget: Gtk.Widget) -> None:
"""Pick an audio file and transcribe it with speaker attribution."""
chooser = Gtk.FileChooserDialog(
title="Transcribe Audio File",
transient_for=None,
action=Gtk.FileChooserAction.OPEN,
)
chooser.add_button("_Cancel", Gtk.ResponseType.CANCEL)
chooser.add_button("_Open", Gtk.ResponseType.OK)
# Non-WAV formats are decoded through ffmpeg; without it only the
# patterns the built-in WAV loader accepts are offered, so the picker
# never advertises a format that cannot be transcribed.
audio_filter = Gtk.FileFilter()
if ffmpeg_available():
audio_filter.set_name("Audio files")
for pattern in ("*.wav", "*.mp3", "*.ogg", "*.flac", "*.m4a", "*.opus"):
audio_filter.add_pattern(pattern)
else:
audio_filter.set_name("WAV audio (install ffmpeg for MP3/OGG/FLAC/M4A/Opus)")
audio_filter.add_pattern("*.wav")
chooser.add_filter(audio_filter)
try:
if chooser.run() == Gtk.ResponseType.OK:
path = chooser.get_filename()
else:
path = None
finally:
chooser.destroy()
if path:
self._start_file_transcription(path)
def _start_file_transcription(self, path: str) -> None:
"""Transcribe ``path`` on a worker, downloading TinyDiarize if needed."""
if not is_model_downloaded(TDRZ_MODEL):
self._offer_tdrz_download(path)
return
basename = os.path.basename(path)
progress = notifications.notify(
"Transcribing audio file",
f"{basename} — this can take a while on long files...",
"audio-x-generic",
)
def run() -> None:
try:
blocks = transcribe_audio_file(path)
except Exception as error:
logger.error("File transcription failed for %s: %s", path, error, exc_info=True)
_idle_once(notifications.close, progress)
_idle_once(
notifications.notify,
"Transcription failed",
f"{basename}: {error}",
"dialog-error",
)
return
_idle_once(notifications.close, progress)
_idle_once(self._show_transcript, basename, blocks)
threading.Thread(target=run, daemon=True, name="file-transcription").start()
def _show_transcript(self, basename: str, blocks: Sequence[TranscriptBlock]) -> None:
"""Open the transcript dialog for the finished blocks."""
from .transcript_dialog import TranscriptDialog
dialog = TranscriptDialog(None, basename)
dialog.set_transcript(blocks)
dialog.show_all()
def _offer_tdrz_download(self, path: str) -> None:
"""Ask to fetch TinyDiarize, then continue into transcription."""
info = WHISPERCPP_MODEL_INFO[TDRZ_MODEL]
prompt = Gtk.MessageDialog(
transient_for=None,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text="Transcribing audio files needs the TinyDiarize model",
)
prompt.format_secondary_text(
f"Download it now? (~{info['size_mb']} MB, one-time). "
"It is a whisper.cpp model that marks speaker turns; it never "
"becomes your dictation model."
)
prompt.add_button("_Cancel", Gtk.ResponseType.CANCEL)
prompt.add_button("_Download", Gtk.ResponseType.OK)
try:
accepted = prompt.run() == Gtk.ResponseType.OK
finally:
prompt.destroy()
if not accepted:
return
if not self.speech_engine.try_begin_download():
notifications.notify(
"Download already in progress",
"Another speech model is being downloaded. Wait for it to finish, "
"then try again.",
"dialog-information",
)
return
dialog = ModelDownloadDialog(
None, TDRZ_MODEL, cast(int, info["size_mb"]), engine="whisper_cpp"
)
# The dialog's post-complete OK button emits a response but destroys
# nothing on its own.
dialog.connect("response", lambda *_args: dialog.destroy())
def run() -> None:
def on_progress(fraction: float, speed_mbps: float, status: str) -> None:
GLib.idle_add(dialog.update_progress, fraction, speed_mbps, status)
def check_cancelled() -> bool:
if dialog.cancelled:
self.speech_engine.cancel_download()
return not dialog.cancelled
cancel_check_id = GLib.timeout_add(100, check_cancelled)
try:
self.speech_engine.set_download_progress_callback(on_progress)
self.speech_engine.download_whispercpp_model(TDRZ_MODEL)
except Exception as error:
logger.error("TinyDiarize download failed: %s", error, exc_info=True)
message = (
"Download cancelled" if "cancelled" in str(error).lower() else str(error)[:100]
)
GLib.idle_add(dialog.set_complete, False, message)
return
finally:
GLib.source_remove(cancel_check_id)
self.speech_engine.set_download_progress_callback(None)
self.speech_engine.end_download()
# The transcript dialog that opens next is the completion
# affordance here: the download dialog is modal, so leaving it
# open behind the transcript keeps its grab and makes the
# transcript's buttons unclickable until it is found and closed.
GLib.idle_add(dialog.destroy)
_idle_once(self._start_file_transcription, path)
threading.Thread(target=run, daemon=True, name="tdrz-download").start()
def _set_indicator_icon(self, icon_key: str, description: str) -> None:
"""Apply a tray icon, forcing a host redraw when cycling reused paths.
Ayatana/GNOME StatusNotifier hosts often skip a redraw when IconName
returns to a path already used earlier in the session. Alternating
IconThemePath between ICON_DIR and ICON_DIR + os.sep triggers a reload
while keeping absolute paths (theme names would regress the stale
~/.local/share/icons hicolor placeholder). Flatpak uses themed names
and does not need the nudge.
"""
if not FLATPAK_ID and self._last_icon_key is not None:
nudge = getattr(self.indicator, "set_icon_theme_path", None)
if callable(nudge):
self._icon_theme_nudge = not self._icon_theme_nudge
nudge(ICON_DIR + os.sep if self._icon_theme_nudge else ICON_DIR)
self.indicator.set_icon_full(icon_key, description)
self._last_icon_key = icon_key
def _set_indicator_title(self, title: str) -> None:
if getattr(self, "_indicator_title", TRAY_TITLE) == title:
return
self.indicator.set_title(title)
self._indicator_title = title
def _update_ui(self, state: RecognitionState) -> bool:
"""
Update the UI based on the recognition state.
Args:
state: Hint from the state-changed callback. The engine's current
state wins: GLib.idle_add can deliver a captured PROCESSING
argument after stop already went IDLE (#739).
"""
engine_state = getattr(self.speech_engine, "state", None)
if isinstance(engine_state, RecognitionState):
state = engine_state
if state == RecognitionState.IDLE and getattr(
self, "_language_shortcuts_refresh_pending", False
):
# The session the listener rebuild was deferred for has ended.
self._language_shortcuts_refresh_pending = False
self.refresh_language_shortcuts()
if not hasattr(self, "indicator"):
return False
is_transcribing = getattr(self.speech_engine, "is_transcribing", None)
transcribing = callable(is_transcribing) and is_transcribing() is True
show_cancel = state == RecognitionState.PROCESSING and transcribing
self._set_cancel_menu_item_visible(show_cancel)
if state == RecognitionState.IDLE:
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.LISTENING:
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["active"], "Microphone on")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.PROCESSING:
if transcribing:
shortcut_manager = getattr(self, "shortcut_manager", None)
name = getattr(shortcut_manager, "shortcut_display_name", None)
if not isinstance(name, str) or not name:
name = "the recording shortcut"
description = f"Transcribing, press {name} again to cancel"
else:
description = "Processing speech"
self._set_indicator_title(description if transcribing else TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["processing"], description)
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", True)
elif state == RecognitionState.ERROR:
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
# Keep floating overlay in sync with tray (same RecognitionState path).
self._update_overlay(state)
return False # Remove idle callback
def _update_overlay(self, state: RecognitionState):
"""Show/hide the floating dictation overlay for the given state."""
if getattr(self, "overlay", None) is None:
return
# Re-read config so Settings toggles apply without restart.
self.overlay.set_enabled(self.config_manager.is_overlay_enabled())
self.overlay.on_recognition_state(state)
def set_overlay_enabled(self, enabled: bool) -> None:
"""Live-update overlay enabled state (called from Settings)."""
self.config_manager.set_overlay_enabled(enabled)
if getattr(self, "overlay", None) is None:
return
self.overlay.set_enabled(enabled)
# Re-apply current recognition state so hide/show is immediate.
self.overlay.on_recognition_state(self.speech_engine.state)
def _set_menu_item_enabled(self, label: str, enabled: bool):
"""
Set the enabled state of a menu item by its label.
Args:
label: The label of the menu item
enabled: Whether the item should be enabled
"""
if not hasattr(self, "menu"):
return
for item in self.menu.get_children():
if isinstance(item, Gtk.MenuItem) and item.get_label() == label:
item.set_sensitive(enabled)
break
self._sync_indicator_menu()
def _set_cancel_menu_item_visible(self, visible: bool) -> None:
item = getattr(self, "_cancel_menu_item", None)
if item is None or getattr(self, "menu", None) is None:
return
item.set_sensitive(visible)
item.set_visible(visible)
self._sync_indicator_menu()
def _on_start_clicked(self, widget):
"""Handle click on the Start Voice Typing menu item."""
logger.debug("Start Voice Typing clicked")
self.speech_engine.start_recognition()
def _on_stop_clicked(self, widget):
"""Handle click on the Stop Voice Typing menu item."""
logger.debug("Stop Voice Typing clicked")
self.speech_engine.stop_recognition()
def _on_cancel_transcription_clicked(self, widget: Any) -> None:
"""Cancel an in-progress transcription from the tray menu."""
logger.debug("Cancel Transcription clicked")
self.speech_engine.cancel_transcription()
def _refresh_history_menu(self) -> bool:
"""Rebuild the Recent Snippets submenu from the current history."""
if self._history_menu_item is None or self.transcription_history is None:
return False # Remove idle callback
submenu = Gtk.Menu()
entries = self.transcription_history.get_all()
if not entries:
empty_item = Gtk.MenuItem.new_with_label("(no snippets yet)")
empty_item.set_sensitive(False)
submenu.append(empty_item)
else:
for entry in entries:
item = Gtk.MenuItem.new_with_label(self._truncate_label(entry))
item.set_tooltip_text(entry)
# The full snippet is passed as connect user-data, so each item
# copies its own text (no late-binding closure pitfall).
item.connect("activate", self._on_history_item_clicked, entry)
submenu.append(item)
submenu.append(Gtk.SeparatorMenuItem())
clear_item = Gtk.MenuItem.new_with_label("Clear History")
clear_item.connect("activate", self._on_clear_history_clicked)
submenu.append(clear_item)
submenu.show_all()
self._history_menu_item.set_submenu(submenu)
self._sync_indicator_menu()
return False # Remove idle callback
@staticmethod
def _truncate_label(text: str) -> str:
"""Collapse whitespace and truncate a snippet for menu display."""
single_line = " ".join(text.split())
if len(single_line) > _HISTORY_LABEL_MAX_CHARS:
return single_line[: _HISTORY_LABEL_MAX_CHARS - 1].rstrip() + "…"
return single_line
def _on_history_item_clicked(self, widget: Gtk.MenuItem, text: str) -> None:
"""Copy the selected snippet to the clipboard."""
logger.debug("History snippet clicked, copying to clipboard")
# Lazy import keeps the module importable when gi.repository is a
# minimal test stand-in (e.g. the AppIndicator fallback tests).
from gi.repository import Gdk
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
clipboard.store()
def _on_clear_history_clicked(self, widget: Gtk.MenuItem) -> None:
"""Clear all stored snippets."""
logger.debug("Clear history clicked")
self.clear_transcription_history()
def clear_transcription_history(self) -> None:
"""Wipe every stored snippet, on disk as well as in memory.
Shared by the tray menu item and the Settings clear button.
"""
if self.transcription_history is not None:
self.transcription_history.clear()
def _on_dictation_pad_clicked(self, widget: Gtk.MenuItem) -> None:
"""Handle click on the Dictation Pad menu item."""
logger.debug("Dictation Pad clicked")
if self.dictation_pad is not None:
self.dictation_pad.show_pad()
def _on_settings_clicked(self, widget):
"""Handle click on the Settings menu item."""
logger.debug("Settings clicked")
self._show_settings_page(None)
def _show_settings_page(self, page_name: Optional[str]) -> None:
"""Open settings, optionally focused on a specific sidebar page."""
dialog = self._settings_dialog
if dialog is not None:
try:
if page_name:
dialog.navigate_to_page(page_name)
self._present_window(dialog)
return
except Exception:
logger.debug("Existing settings dialog is gone, opening a new one")
self._settings_dialog = None
dialog = SettingsDialog(
parent=None,
config_manager=self.config_manager,
speech_engine=self.speech_engine,
shortcut_update_callback=self.update_shortcut,
initial_page=page_name,
pending_update=self._pending_update,
# About checks should refresh the tray item without re-notifying.
update_status_callback=lambda available, release: self._apply_update_status(
available, release, notify=False
),
update_checks_callback=self._on_update_checks_toggled,
overlay_enabled_callback=self.set_overlay_enabled,
hotkey_listener_update_callback=self._setup_keyboard_shortcuts,
hotkey_passthrough_update_callback=self.update_shortcut_passthrough,
language_shortcuts_update_callback=self.refresh_language_shortcuts,
history_update_callback=self._apply_history_settings,
history_clear_callback=self.clear_transcription_history,
dictation_pad_open_callback=(
self.dictation_pad.show_pad if self.dictation_pad is not None else None
),
)
dialog.connect("response", self._on_settings_dialog_response)
dialog.connect("destroy", self._on_settings_dialog_destroyed)
self._settings_dialog = dialog
dialog.show()
def _present_window(self, window: Gtk.Window) -> None:
"""Raise an already-open window so a second tray click does not spawn another."""
window.deiconify()
window.present_with_time(Gtk.get_current_event_time())
def _on_settings_dialog_destroyed(self, dialog, *_args) -> None:
"""Drop the settings dialog reference after it is closed."""
if self._settings_dialog is dialog:
self._settings_dialog = None
def _update_checks_enabled(self) -> bool:
"""Return whether background update checks are enabled."""
return self.config_manager.get_bool(
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
)
def _on_update_checks_toggled(self, enabled: bool) -> None:
"""Update tray state when automatic update checks change."""
if not enabled:
self._apply_update_status(False, None, notify=False)
else:
self._update_monitor.check_now()
def _on_update_check_result(self, available: bool, release: Optional[ReleaseInfo]):
"""Handle a background update-check result (already on the GLib main loop)."""
self._apply_update_status(available, release, notify=True)
def _apply_update_status(
self,
available: bool,
release: Optional[ReleaseInfo],
*,
notify: bool,
) -> None:
"""Update tray pending-update state and optional desktop notification."""
self._pending_update = release if available else None
if not hasattr(self, "menu"):
return
if available and release is not None:
self._show_update_menu_item(release)
if notify:
self._maybe_notify_update(release)
elif self._update_menu_item is not None:
self._update_menu_item.hide()
self._sync_indicator_menu()
def _show_update_menu_item(self, release: ReleaseInfo) -> None:
"""Reveal the tray menu entry that opens About / release notes."""
if self._update_menu_item is None:
return
packaged = is_packaged_install()
self._update_menu_item.set_label(update_menu_label(release.tag_name, packaged=packaged))
self._update_menu_item.set_tooltip_text(update_menu_tooltip(packaged=packaged))
self._update_menu_item.show()
self._sync_indicator_menu()
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
"""Send one desktop notification per new version when notifications are on."""
if not self._update_checks_enabled():
return
if not self.config_manager.get_bool("ui", "show_notifications", True):
return
if self.config_manager.get_str("updates", "last_notified_version", "") == release.tag_name:
return
# Persist only after notify-send is successfully spawned so a missing
# binary does not permanently suppress the alert for this version.
try:
import subprocess
subprocess.Popen(
[
"notify-send",
"-i",
"software-update-available",
"-a",
TRAY_TITLE,
f"{TRAY_TITLE} update available",
update_notification_body(release.tag_name, packaged=is_packaged_install()),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=host_env(),
)
except (FileNotFoundError, OSError) as exc:
logger.debug("Could not show update notification: %s", exc)
return
self.config_manager.set("updates", "last_notified_version", release.tag_name)
self.config_manager.save_settings()
def _on_update_available_clicked(self, widget):
"""Open Settings on About so the user can read notes and open the release."""
logger.debug("Update Available clicked")
self._show_settings_page("about")
def _on_logs_clicked(self, widget):
"""Handle click on the View Logs menu item."""
logger.debug("View Logs clicked")
dialog = self._logging_dialog
if dialog is not None:
try:
self._present_window(dialog)
return
except Exception:
logger.debug("Existing logs dialog is gone, opening a new one")
self._logging_dialog = None
# Import here to avoid circular imports
from .logging_dialog import LoggingDialog
dialog = LoggingDialog(parent=None)
dialog.connect("destroy", self._on_logging_dialog_destroyed)
self._logging_dialog = dialog
dialog.show()
def _on_logging_dialog_destroyed(self, dialog, *_args) -> None:
"""Drop the logs dialog reference after it is closed."""
if self._logging_dialog is dialog:
self._logging_dialog = None
def _on_settings_dialog_response(self, dialog, response):
"""Handle responses from the settings dialog."""
# With auto-apply, we just close the dialog on any response
if response == Gtk.ResponseType.CLOSE or response == Gtk.ResponseType.DELETE_EVENT:
logger.info("Settings dialog closed.")
dialog.destroy()
def update_shortcut(self, shortcut: str, mode: Optional[str] = None) -> bool:
"""
Update the keyboard shortcut for toggling voice recognition.
This performs a live shortcut switch without requiring an app restart.
Args:
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
mode: Optional new mode ("toggle" or "push_to_talk"). If None, keeps current mode.
Returns:
True if the shortcut was updated successfully, False otherwise
"""
current_mode = self.shortcut_manager.mode
mode_changed = mode is not None and mode != current_mode
shortcut_changed = shortcut != self.shortcut_manager.shortcut
if mode_changed:
logger.info(f"Mode changing from {current_mode} to {mode}")
assert mode is not None
if not self.shortcut_manager.set_mode(mode):
logger.error(f"Failed to set shortcut mode: {mode}")
return False
if shortcut_changed:
if not self.shortcut_manager.set_shortcut(shortcut):
logger.error(f"Failed to set shortcut: {shortcut}")
return False
if mode_changed or shortcut_changed:
self._setup_keyboard_shortcuts()
return self.shortcut_manager.active
logger.debug("No changes needed - shortcut and mode unchanged")
return True
def _get_auto_pause_config(self):
"""Return (enabled, apps, poll_interval_seconds) for AutoPauseMonitor."""
enabled = self.config_manager.get_bool("auto_pause", "enabled", False)
apps = self.config_manager.get("auto_pause", "apps", []) or []
if not isinstance(apps, list):
apps = []
interval = self.config_manager.get_float(
"auto_pause", "poll_interval_seconds", float(DEFAULT_POLL_INTERVAL_SECONDS)
)
return enabled, apps, interval
def _get_model_keepalive_config(self):
"""Return (enabled, idle_timeout_seconds) for ModelKeepAlive."""
enabled = self.config_manager.get_bool("model_keepalive", "enabled", False)
timeout = self.config_manager.get_float(
"model_keepalive",
"idle_timeout_seconds",
float(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
return enabled, timeout
def _is_safe_for_keepalive_unload(self) -> bool:
"""Keep-alive may unload only when idle and not under auto-pause."""
if getattr(self.speech_engine, "is_auto_paused", False) is True:
return False
if (
getattr(self, "_auto_pause_monitor", None) is not None
and self._auto_pause_monitor.paused
):
return False
return self.speech_engine.state == RecognitionState.IDLE
def _on_keepalive_idle_unload(self):
"""Unload speech model after idle keep-alive timeout."""
logger.info("Keep-alive idle timeout — unloading speech model")
unload = getattr(self.speech_engine, "unload_model", None)
if callable(unload):
unload(reason="idle_keepalive")
def _on_auto_pause(self):
"""Unload speech model when a configured game/app is detected."""
logger.info("Auto-pause triggered — unloading speech model")
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.cancel()
unload = getattr(self.speech_engine, "unload_model", None)
if callable(unload):
unload(reason="auto_pause")
else:
# Fallback for engines that only expose reinit/stop
if self.speech_engine.state != RecognitionState.IDLE:
self.speech_engine.stop_recognition()
def _on_auto_resume(self) -> None:
"""Reload speech model after configured games/apps have exited."""
if getattr(self, "_system_suspended", False):
# Wake will recover even if the monitor does not transition again.
return
logger.info("Auto-pause cleared — reloading speech model")
reinit = getattr(self.speech_engine, "reinitialize_after_resume", None)
if callable(reinit):
try:
reinit(auto_resume=True)
except Exception:
logger.error("Failed to reload speech engine after auto-pause", exc_info=True)
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.bump()
def _on_system_suspend(self) -> None:
"""Stop active recognition before the system goes to sleep."""
self._system_suspended = True
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(monitor, "paused", False) is True:
# Replace pending initialization with a current unload obligation;
# invalidating it alone would lose cleanup until the game exits.
self._on_auto_pause()
else:
cancel_pending = getattr(self.speech_engine, "cancel_pending_auto_pause", None)
if callable(cancel_pending):
cancel_pending()
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.cancel()
# Release the internal hotkey listeners (and their EVIOCGRABs)
# before the machine sleeps: a grab held through suspend keeps
# feeding a forwarder whose liveness across resume cannot be
# guaranteed, and the lock screen ends up behind a dead input
# path (#1021). The resume flow rebuilds the listeners once
# /dev/input settles.
self.shortcut_manager.stop()
self._stop_language_shortcut_managers()
if self.speech_engine.state != RecognitionState.IDLE:
logger.info("System suspending — stopping active recognition")
self.speech_engine.stop_recognition()
def _on_system_resume(self) -> None:
"""Reinitialize subsystems after the system wakes up.
Speech engine reinitializes after 2s (audio hardware recovers fast).
Keyboard backend waits for /dev/input to settle using inotify-backed
directory monitoring, with a timer fallback if monitoring unavailable.
If auto-pause still has a configured app running, skip speech reinit —
the auto-pause monitor owns model lifecycle until that app exits.
"""
self._system_suspended = False
logger.info("System resumed — scheduling reinit")
# The manager also stays guarded during deferred auto-resume. If sleep
# cancelled that work after the game exited, wake must finish recovery.
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(monitor, "paused", False) is True or (
monitor is None and getattr(self.speech_engine, "is_auto_paused", False) is True
):
logger.info("Skipping resume reinit: auto-pause still active")
else:
# Own recovery during the hardware-settle delay too, so a failed
# settings change can inherit it while superseding this timer.
prepare = getattr(self.speech_engine, "prepare_auto_resume", None)
request = prepare() if callable(prepare) else None
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume, request)
# External-activation mode never started the /dev/input listener in
# the first place; watching it here on every resume would open the
# very file descriptor that mode promises to avoid.
if self._external_activation_active():
logger.info("Skipping input device monitor: external activation via D-Bus")
else:
GLib.timeout_add_seconds(2, self._start_input_device_monitor)
def _reinit_speech_after_resume(self, request: Optional[object] = None) -> bool:
"""Recover only while this wake still owns the model lifecycle."""
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(self, "_system_suspended", False) or getattr(monitor, "paused", False) is True:
return False
try:
reinit = getattr(self.speech_engine, "reinitialize_after_resume", None)
if callable(reinit):
reinit(auto_resume=True, expected_request=request)
except Exception:
logger.error("Failed to reinitialize after resume", exc_info=True)
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.bump()
return False
def _start_input_device_monitor(self):
"""Watch /dev/input for changes; restart keyboard when devices settle."""
try:
gfile = Gio.File.new_for_path("/dev/input")
self._input_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
self._input_monitor.connect("changed", self._on_input_device_changed)
self._settle_timer_id = GLib.timeout_add_seconds(
_INPUT_SETTLE_SECONDS, self._on_devices_settled
)
self._monitor_timeout_id = GLib.timeout_add_seconds(
_INPUT_MONITOR_CAP_SECONDS, self._on_input_monitor_timeout
)
logger.info("Monitoring /dev/input for device changes after resume")
except Exception:
logger.error("Failed to monitor /dev/input, falling back to timer", exc_info=True)
GLib.timeout_add_seconds(
_FALLBACK_KEYBOARD_RESTART_SECONDS, self._reinit_keyboard_fallback
)
return GLib.SOURCE_REMOVE
def _on_input_device_changed(self, monitor, file, other_file, event_type):
if getattr(self, "_settle_timer_id", None) is not None:
GLib.source_remove(self._settle_timer_id)
self._settle_timer_id = GLib.timeout_add_seconds(
_INPUT_SETTLE_SECONDS, self._on_devices_settled
)
def _on_devices_settled(self):
logger.info("Input devices settled — restarting keyboard shortcuts")
self._cleanup_input_monitor()
self._setup_keyboard_shortcuts()
return GLib.SOURCE_REMOVE
def _on_input_monitor_timeout(self):
logger.warning("Input device monitor timed out — restarting keyboard shortcuts")
self._cleanup_input_monitor()
self._setup_keyboard_shortcuts()
return GLib.SOURCE_REMOVE
def _reinit_keyboard_fallback(self):
logger.info("Restarting keyboard shortcuts (fallback timer)")
self._setup_keyboard_shortcuts()
return GLib.SOURCE_REMOVE
def _cleanup_input_monitor(self):
if getattr(self, "_settle_timer_id", None) is not None:
GLib.source_remove(self._settle_timer_id)
self._settle_timer_id = None
if getattr(self, "_monitor_timeout_id", None) is not None:
GLib.source_remove(self._monitor_timeout_id)
self._monitor_timeout_id = None
if getattr(self, "_input_monitor", None) is not None:
self._input_monitor.cancel()
self._input_monitor = None
def _on_gateway_status_for_tray(self, status: GatewayStatus, detail: str) -> None:
"""Update tray Stop local Gateway visibility from a worker thread."""
GLib.idle_add(self._sync_gateway_stop_menu, status)
def _sync_gateway_stop_menu(self, status: GatewayStatus) -> bool:
item = getattr(self, "_gateway_stop_menu_item", None)
if item is None:
return False
# Show Stop for leftover compose too; managed_by_us is session memory only.
show = status in {
GatewayStatus.STARTING,
GatewayStatus.LIVE,
GatewayStatus.PAIRABLE,
GatewayStatus.READY,
GatewayStatus.ERROR,
}
if show:
item.show()
else:
item.hide()
self._sync_indicator_menu()
return False
def _on_stop_local_gateway_clicked(self, widget: Any) -> None:
"""Stop local compose, including leftovers from a previous session."""
get_gateway_embed_manager().stop_async()
def _on_quit_clicked(self, widget):
"""Handle click on the Quit menu item."""
logger.debug("Quit clicked")
self._quit()
def _quit(self):
"""Quit the application."""
logger.info("Quitting application")
# stop_recognition is not called here (it would play the stop cue and
# join the capture thread). Put the speakers back before the process
# exits; a crash that skips this still restores on the next launch.
engine = getattr(self, "speech_engine", None)
release = getattr(engine, "release_playback_duck", None)
if callable(release):
try:
release()
except Exception:
logger.error("Could not restore playback volume while quitting", exc_info=True)
if self._suspend_handler is not None:
self._suspend_handler.shutdown()
if getattr(self, "_auto_pause_monitor", None) is not None:
self._auto_pause_monitor.shutdown()
if getattr(self, "_model_keepalive", None) is not None:
self._model_keepalive.shutdown()
if getattr(self, "_update_monitor", None) is not None:
self._update_monitor.shutdown()
if getattr(self, "_dbus_service", None) is not None:
self._dbus_service.shutdown()
self._cleanup_input_monitor()
# Stop the keyboard shortcut managers
self.shortcut_manager.stop()
self._stop_language_shortcut_managers()
if getattr(self, "overlay", None) is not None:
self.speech_engine.unregister_audio_level_callback(self.overlay.set_audio_level)
self.overlay.destroy()
self.overlay = None
# Drain the post-processing worker before the injector stops: queued
# segments are cancelled and a running job drops its result, so no
# injection can land once the injector is gone.
if getattr(self, "_on_quit", None) is not None:
try:
self._on_quit()
except Exception:
logger.error("Error draining post-processing worker while quitting", exc_info=True)
# Stop the text injector (restores previous IBus engine)
if hasattr(self, "text_injector") and self.text_injector is not None:
self.text_injector.stop()
Gtk.main_quit()
def run(self):
"""Run the application main loop."""
logger.info("Starting GTK main loop")
# Set up signal handlers for graceful termination
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
# Start the GTK main loop
try:
Gtk.main()
except KeyboardInterrupt:
self._quit()
def _signal_handler(self, sig, frame):
"""
Handle signals (e.g., SIGINT, SIGTERM).
Args:
sig: The signal number
frame: The current stack frame
"""
logger.info(f"Received signal {sig}, shutting down...")
GLib.idle_add(self._quit)