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.
2008 lines
86 KiB
Python
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)
|