Files
voculinux/src/vocalinux/ui/tray_indicator.py
T
Jatin K MalikandGitHub eef2554f7c feat(audio): lower other audio while dictating (#861)
* feat(audio): lower other audio while dictating

Opt-in setting lowers the default PipeWire or PulseAudio sink while the
microphone is open, then restores that exact level. It stays off by default.

* fix(test): keep pytest.approx working when numpy is mocked

Other test modules replace sys.modules["numpy"] with a MagicMock at import
and leave it there. pytest.approx then crashes on isinstance(..., numpy.bool_).

* fix(audio): restore every channel and do not duck if it cannot be undone

A failed restore-point write no longer lowers the sink. PulseAudio puts
each channel back, and a dead microphone restores playback instead of
waiting for an explicit stop. The recognition test patches the real
audio module when an older test left a mock behind.

* fix(test): put the audio-feedback mock back after the ducking test

The recognition test has to patch the real module, but later tests still
expect the mock that test_text_injector installs. Restore that mock when
the test ends, and annotate the new helpers.

* fix(test): annotate the playback-duck tests

AGENTS.md requires a type hint on every new function. These tests were the
ones still missing them.

* fix(audio): skip arming playback duck after recording already failed

* chore: retrigger review after rebase
2026-09-29 14:24:28 -07:00

1197 lines
48 KiB
Python

"""
System tray indicator module for Vocalinux.
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 typing import Callable, Optional
import gi
# Import GTK
gi.require_version("Gtk", "3.0")
# 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.
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except (ImportError, ValueError):
try:
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 as AppIndicator3
except (ImportError, ValueError):
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
# 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 ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive
from ..suspend_handler import SuspendHandler
from ..utils.host_process import host_env
from ..utils.resource_manager import ResourceManager
from ..utils.update_checker import ReleaseInfo
from ..utils.update_monitor import UpdateMonitor
from . import notifications
from .config_manager import get_shared_config_manager
from .keyboard_backends import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE
from .keyboard_shortcuts import KeyboardShortcutManager
from .settings_dialog import SettingsDialog, recommended_model_for_engine
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 "vocalinux"
# 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 = "vocalinux-microphone-off"
ACTIVE_ICON = "vocalinux-microphone"
PROCESSING_ICON = "vocalinux-microphone-process"
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,
}
# /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 Vocalinux.
This class provides a system tray icon with a menu for controlling
the speech recognition process.
"""
def __init__(
self,
speech_engine: SpeechRecognitionManagerProtocol,
text_injector: TextInjectorProtocol,
):
"""
Initialize the system tray indicator.
Args:
speech_engine: The speech recognition manager instance
text_injector: The text injector instance
"""
self.speech_engine = speech_engine
self.text_injector = text_injector
# 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()
self._syncing_autostart_menu = False
# 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)
# 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 vocalinux-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
# 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)
# 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._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_channel=self._get_update_channel,
on_result=self._on_update_check_result,
)
self._update_monitor.start()
# Set up keyboard shortcuts with mode support
self._setup_keyboard_shortcuts()
def _setup_keyboard_shortcuts(self):
"""Set up keyboard shortcuts based on configured mode."""
# 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)
# 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._stop_recognition)
# Start the keyboard shortcut manager
self.shortcut_manager.start()
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:
self.indicator = AppIndicator3.Indicator.new(
APP_ID,
initial_icon,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
)
else:
self.indicator = AppIndicator3.Indicator.new_with_path(
APP_ID,
initial_icon,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
self.indicator.set_icon_theme_path(ICON_DIR)
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
self.indicator.set_title("Vocalinux")
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._add_menu_separator()
self._autostart_menu_item = self._add_menu_checkbox(
"Start on Login", self._on_autostart_toggled
)
self._update_autostart_checkbox()
self._add_menu_separator()
self._add_menu_item("Settings", self._on_settings_clicked)
self._add_menu_item("View Logs", self._on_logs_clicked)
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("About", self._on_about_clicked)
self._add_menu_item("Quit", self._on_quit_clicked)
# Set the indicator menu
self.indicator.set_menu(self.menu)
# Show the menu
self.menu.show_all()
# 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)
# 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(
"Vocalinux 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):
"""Toggle the recognition state between IDLE and LISTENING."""
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition()
else:
self.speech_engine.stop_recognition()
def _start_recognition(self):
"""Start voice recognition (for push-to-talk mode)."""
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition(mode="push_to_talk")
def _stop_recognition(self):
"""Stop voice recognition (for push-to-talk mode)."""
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 _add_menu_checkbox(self, label: str, callback: Callable) -> Gtk.CheckMenuItem:
"""
Add a checkbox menu item to the indicator menu.
Args:
label: The label for the menu item
callback: The callback function to call when the item is toggled
Returns:
The checkbox menu item
"""
item = Gtk.CheckMenuItem.new_with_label(label)
item.connect("toggled", callback)
self.menu.append(item)
return item
def _update_autostart_checkbox(self):
"""Update the autostart checkbox state based on current config."""
from . import autostart_manager
autostart_enabled = autostart_manager.is_autostart_enabled()
config_enabled = self.config_manager.get_bool("general", "autostart", False)
if config_enabled != autostart_enabled:
self.config_manager.set("general", "autostart", autostart_enabled)
self.config_manager.save_settings()
self._syncing_autostart_menu = True
self._autostart_menu_item.set_active(autostart_enabled)
self._syncing_autostart_menu = False
def _on_autostart_toggled(self, widget):
"""Handle toggle of the Start on Login menu item."""
if self._syncing_autostart_menu:
return
enabled = widget.get_active()
logger.info(f"Autostart toggled: {enabled}")
from . import autostart_manager
if autostart_manager.set_autostart(enabled):
self.config_manager.set("general", "autostart", enabled)
self.config_manager.save_settings()
status = "enabled" if enabled else "disabled"
logger.info(f"Autostart {status}")
else:
self._syncing_autostart_menu = True
widget.set_active(not enabled)
self._syncing_autostart_menu = False
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()
# 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, speed_mbps, status):
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)
self.speech_engine.reconfigure(
engine=engine,
model_size=recommendation.model_id,
force_download=True,
# reconfigure() only re-initializes when engine, model or
# language change, and force_download is read during that
# re-init. The recommendation is often what is already
# configured (first run is whisper_cpp + tiny, and tiny is what
# a CPU-only machine is recommended), so without this the call
# is a no-op that downloads nothing.
force_reinit=True,
)
if not self.speech_engine.model_ready:
raise RuntimeError("the engine did not load the model after downloading it")
# Persist only now: the model is on disk and the engine loaded it.
self.config_manager.set_model_size_for_engine(engine, recommendation.model_id)
self.config_manager.save_config()
shortcut = self.config_manager.get_str(
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
)
_idle_once(notifications.close, progress)
_idle_once(
notifications.notify,
"Speech model ready",
f"{recommendation.display_name} is installed. Press {shortcut} to dictate.",
"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.set_download_progress_callback(None)
self.speech_engine.end_download()
self._model_download_active = False
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 _update_ui(self, state: RecognitionState):
"""
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).
"""
if not hasattr(self, "indicator"):
return False
engine_state = getattr(self.speech_engine, "state", None)
if isinstance(engine_state, RecognitionState):
state = engine_state
if state == RecognitionState.IDLE:
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_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:
self._set_indicator_icon(self._icon_keys["processing"], "Processing speech")
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_icon(self._icon_keys["default"], "Error")
self._set_menu_item_enabled("Start Voice Typing", True)
self._set_menu_item_enabled("Stop Voice Typing", False)
return False # Remove idle callback
def _set_menu_item_enabled(self, label: str, enabled: bool):
"""
Set the enabled state of a menu item by its label.
Args:
label: The label of the menu item
enabled: Whether the item should be enabled
"""
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
def _on_start_clicked(self, widget):
"""Handle click on the Start Voice Typing menu item."""
logger.debug("Start Voice Typing clicked")
self.speech_engine.start_recognition()
def _on_stop_clicked(self, widget):
"""Handle click on the Stop Voice Typing menu item."""
logger.debug("Stop Voice Typing clicked")
self.speech_engine.stop_recognition()
def _on_settings_clicked(self, widget):
"""Handle click on the Settings menu item."""
logger.debug("Settings clicked")
self._show_settings_page(None)
def _show_settings_page(self, page_name: Optional[str]):
"""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
),
)
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 _get_update_channel(self) -> str:
"""Return the configured release channel for background update checks."""
return self.config_manager.get_str("updates", "channel", "stable")
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()
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
label = f"Update Available ({release.tag_name})…"
self._update_menu_item.set_label(label)
self._update_menu_item.set_tooltip_text(
"Open Settings → About for release notes and download links"
)
self._update_menu_item.show()
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
"""Send one desktop notification per new version when notifications are on."""
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",
"Vocalinux",
"Vocalinux update available",
f"{release.tag_name} is ready. Open the tray menu or "
"Settings → About for release notes.",
],
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 _on_about_clicked(self, widget):
"""Handle click on the About menu item."""
logger.debug("About clicked")
self._show_settings_page("about")
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):
"""Reload speech model after configured games/apps have exited."""
logger.info("Auto-pause cleared — reloading speech model")
reinit = getattr(self.speech_engine, "reinitialize_after_resume", None)
if callable(reinit):
try:
reinit()
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):
"""Stop active recognition before the system goes to sleep."""
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.cancel()
if self.speech_engine.state != RecognitionState.IDLE:
logger.info("System suspending — stopping active recognition")
self.speech_engine.stop_recognition()
def _on_system_resume(self):
"""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.
"""
logger.info("System resumed — scheduling reinit")
# Use `is True` so duck-typed mocks without a real bool flag still reinit.
if getattr(self.speech_engine, "is_auto_paused", False) is True:
logger.info("Skipping resume reinit: auto-pause still active")
else:
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume)
GLib.timeout_add_seconds(2, self._start_input_device_monitor)
def _reinit_speech_after_resume(self):
try:
self.speech_engine.reinitialize_after_resume()
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 GLib.SOURCE_REMOVE
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_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()
self._cleanup_input_monitor()
# Stop the keyboard shortcut manager
self.shortcut_manager.stop()
# 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)