feat(offload): auto-pause apps + idle keep-alive model unload (#592)

* feat(auto-pause): unload speech model while selected apps run

Add Settings toggle + process list so Vocalinux frees CPU/GPU/RAM while
configured games/apps are running, then reloads the model when they exit.
Closes the process-list half of #445 (window-focus pause is separate).

* test(auto-pause): isolate tray wiring to avoid stale GLib mocks

Move TrayIndicator auto-pause tests into test_suspend_handler so
test_auto_pause_monitor never imports tray_indicator under a collection-time
gi MagicMock. That import pinned GLib.SOURCE_REMOVE and broke resume tests
later in the suite (CI on #517).

* test(auto-pause): raise patch coverage for codecov target

Cover process snapshot I/O, GLib poll scheduling, config coercion,
unload edge cases, and tray fallback paths so codecov/patch clears 80%.

* feat(keepalive): idle model offload for GPU/battery saving

Add opt-in keep-alive that unloads the speech model after inactivity so
hybrid-GPU laptops can release VRAM/Vulkan. Next dictation lazy-reloads.
Shares unload_model with auto-pause (#445) using distinct reasons so
app-pause still blocks dictation while idle unload does not.

Fixes #591 (with #445 via shared offload infrastructure).

* test(keepalive): mock gi.repository.GLib via sys.modules

Match the auto-pause GLib test pattern so CI on Python 3.9/3.10
sees the late `from gi.repository import GLib` import.

* test(keepalive): drop post-fire source_remove assertion

After the GLib timeout callback runs, _timeout_id is already cleared,
so shutdown does not call source_remove.

* fix(settings): hide auto-pause empty-state label when apps are listed

The dialog-wide show_all() after _load_and_apply_settings() re-showed
the 'No apps listed yet' row that _refresh_auto_pause_list had hidden.
Mark the row no_show_all and manage visibility explicitly.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* feat(settings): move auto-pause and idle unload to dedicated Power tab

- New Power tab between Shortcuts and General so resource management
  features are easy to find; General returns to app-behavior toggles only
- Rename 'Model Keep-Alive (Battery)' to 'Unload When Idle' — the old
  name read as the opposite of what the feature does
- Clearer copy throughout: row titles, descriptions, tooltips,
  'From running…' becomes 'Choose Running App…', empty-state hint
- Gray out app-list and idle-timeout controls while their feature
  toggle is off

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

* fix(settings): show which notebook tab is selected

The tab CSS targeted :selected, but GTK3 marks the active notebook tab
with :checked, so no selected styling ever applied — all tabs rendered
identically. Use :checked and add an accent underline + bold label.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-26 22:29:00 -07:00
committed by GitHub
co-authored by Jatin K Malik Cursor Agent
parent b945137d60
commit 4c070ded46
9 changed files with 2107 additions and 10 deletions
+255
View File
@@ -0,0 +1,255 @@
"""
Auto-pause monitor: unload speech model while selected apps/games are running.
Polls the process table for configured executable basenames. When any match
is found, invokes an on_pause callback (caller should stop dictation and
unload the model). When no matches remain, invokes on_resume (reload model).
Matching is intentionally pure and unit-testable; I/O lives in helpers used
by the poller.
"""
from __future__ import annotations
import logging
import os
from typing import Callable, Iterable, Optional, Sequence
logger = logging.getLogger(__name__)
# Default poll interval when config does not specify one
DEFAULT_POLL_INTERVAL_SECONDS = 5
# Reasonable bounds for poll interval (seconds)
_MIN_POLL_INTERVAL = 1
_MAX_POLL_INTERVAL = 60
def normalize_process_name(name: str) -> str:
"""Normalize a process or configured app name for comparison.
Uses lowercase basename and strips a trailing ``.exe`` so wine/Proton
games match the same configured name as native processes.
"""
if not name:
return ""
base = os.path.basename(name.strip()).lower()
if base.endswith(".exe"):
base = base[:-4]
return base
def configured_names_set(apps: Sequence[str]) -> set[str]:
"""Return the set of normalized configured process names (empty entries dropped)."""
return {normalize_process_name(a) for a in apps if a and str(a).strip()}
def any_configured_process_running(
configured_apps: Sequence[str],
running_names: Iterable[str],
) -> bool:
"""Return True if any configured app name matches a running process name.
Matching is case-insensitive basename equality after ``normalize_process_name``.
An empty configured list never matches.
"""
targets = configured_names_set(configured_apps)
if not targets:
return False
for raw in running_names:
if normalize_process_name(raw) in targets:
return True
return False
def collect_running_process_names() -> set[str]:
"""Snapshot names and exe basenames of currently running processes via psutil."""
import psutil
names: set[str] = set()
for proc in psutil.process_iter(["name", "exe"]):
try:
info = proc.info
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
name = info.get("name")
if name:
names.add(name)
exe = info.get("exe")
if exe:
try:
names.add(os.path.basename(exe))
except Exception:
pass
return names
class AutoPauseMonitor:
"""Polls for configured processes and fires pause/resume callbacks.
Designed like :class:`~vocalinux.suspend_handler.SuspendHandler`: the tray
owns lifecycle (start with the app, stop on quit). Callbacks run on the
GLib main loop when a timeout source is used.
Args:
get_config: Callable returning ``(enabled, apps, poll_interval_seconds)``.
Re-read every poll so Settings changes apply without restart.
on_pause: Called once when the match set becomes non-empty.
on_resume: Called once when the match set becomes empty after a pause.
process_snapshot: Optional override for :func:`collect_running_process_names`
(used in tests).
use_glib: If True (default), schedule polls with GLib.timeout_add_seconds.
If False, only ``check_once`` / manual ``poll`` is available (tests).
"""
def __init__(
self,
get_config: Callable[[], tuple[bool, Sequence[str], float]],
on_pause: Optional[Callable[[], None]] = None,
on_resume: Optional[Callable[[], None]] = None,
process_snapshot: Optional[Callable[[], Iterable[str]]] = None,
use_glib: bool = True,
):
self._get_config = get_config
self._on_pause = on_pause
self._on_resume = on_resume
self._process_snapshot = process_snapshot or collect_running_process_names
self._use_glib = use_glib
self._paused = False
self._timeout_id: Optional[int] = None
self._running = False
self._last_poll_interval = DEFAULT_POLL_INTERVAL_SECONDS
@property
def paused(self) -> bool:
"""True while a configured process is considered active (after on_pause)."""
return self._paused
@property
def active(self) -> bool:
"""True if the poller is scheduled / running."""
return self._running
def start(self) -> None:
"""Start periodic polling. Safe to call if already started."""
if self._running:
return
self._running = True
if self._use_glib:
self._schedule_next_poll(immediate=True)
logger.info("Auto-pause monitor started")
def stop(self) -> None:
"""Stop polling and clear the GLib timeout source."""
self._running = False
self._cancel_timeout()
logger.info("Auto-pause monitor stopped")
def shutdown(self) -> None:
"""Alias for :meth:`stop` (mirrors SuspendHandler.shutdown)."""
self.stop()
def check_once(self) -> bool:
"""Run a single poll cycle. Returns whether a configured process matched.
Safe for unit tests without GLib. Updates paused state and may invoke
on_pause / on_resume.
"""
enabled, apps, _interval = self._read_config()
if not enabled:
self._clear_pause_if_needed()
return False
running = self._process_snapshot()
matched = any_configured_process_running(apps, running)
if matched:
self._enter_pause_if_needed()
else:
self._clear_pause_if_needed()
return matched
def _read_config(self) -> tuple[bool, Sequence[str], float]:
try:
enabled, apps, interval = self._get_config()
except Exception:
logger.error("Failed to read auto-pause config", exc_info=True)
return False, [], DEFAULT_POLL_INTERVAL_SECONDS
if not isinstance(apps, (list, tuple)):
apps = []
try:
interval_f = float(interval)
except (TypeError, ValueError):
interval_f = DEFAULT_POLL_INTERVAL_SECONDS
interval_f = max(_MIN_POLL_INTERVAL, min(_MAX_POLL_INTERVAL, interval_f))
return bool(enabled), apps, interval_f
def _enter_pause_if_needed(self) -> None:
if self._paused:
return
self._paused = True
logger.info("Auto-pause: configured app detected — pausing / unloading model")
if self._on_pause:
try:
self._on_pause()
except Exception:
logger.error("Error in auto-pause on_pause callback", exc_info=True)
def _clear_pause_if_needed(self) -> None:
if not self._paused:
return
self._paused = False
logger.info("Auto-pause: no configured apps running — resuming / reloading model")
if self._on_resume:
try:
self._on_resume()
except Exception:
logger.error("Error in auto-pause on_resume callback", exc_info=True)
def _schedule_next_poll(self, immediate: bool = False) -> None:
if not self._running or not self._use_glib:
return
self._cancel_timeout()
_, _, interval = self._read_config()
self._last_poll_interval = interval
try:
from gi.repository import GLib
except Exception:
logger.warning("GLib unavailable; auto-pause poller not scheduled")
return
delay = 0 if immediate else int(interval)
# GLib.timeout_add_seconds requires >= 1 for seconds API when non-zero;
# use timeout_add(0) equivalent via idle for immediate first check.
if delay <= 0:
self._timeout_id = GLib.idle_add(self._glib_poll)
else:
self._timeout_id = GLib.timeout_add_seconds(delay, self._glib_poll)
def _cancel_timeout(self) -> None:
if self._timeout_id is None:
return
try:
from gi.repository import GLib
GLib.source_remove(self._timeout_id)
except Exception:
pass
self._timeout_id = None
def _glib_poll(self) -> bool:
"""GLib source callback: poll once then reschedule with current interval."""
self._timeout_id = None
if not self._running:
return False # SOURCE_REMOVE
try:
self.check_once()
except Exception:
logger.error("Auto-pause poll failed", exc_info=True)
# Reschedule so interval changes from Settings take effect
if self._running:
self._schedule_next_poll(immediate=False)
return False # always remove this source; we reschedule explicitly
+185
View File
@@ -0,0 +1,185 @@
"""
Model keep-alive: unload the speech model after a configurable idle timeout.
Mirrors :class:`~vocalinux.auto_pause_monitor.AutoPauseMonitor` / SuspendHandler
style — the tray owns lifecycle, callbacks run on the GLib main loop when used
with ``use_glib=True``. Designed so unit tests can drive the timer without GTK.
"""
from __future__ import annotations
import logging
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Default idle timeout when keep-alive is enabled but config omits a value
DEFAULT_IDLE_TIMEOUT_SECONDS = 300
# Reasonable bounds when enabled (seconds)
_MIN_IDLE_TIMEOUT = 60
_MAX_IDLE_TIMEOUT = 3600
def clamp_idle_timeout(seconds: float) -> float:
"""Clamp an idle timeout to the supported range."""
try:
value = float(seconds)
except (TypeError, ValueError):
return float(DEFAULT_IDLE_TIMEOUT_SECONDS)
return max(_MIN_IDLE_TIMEOUT, min(_MAX_IDLE_TIMEOUT, value))
class ModelKeepAlive:
"""Schedules idle unload of the speech model after inactivity.
Args:
get_config: Callable returning ``(enabled, idle_timeout_seconds)``.
Re-read on each bump so Settings changes apply without restart.
on_idle_unload: Called once when the idle timeout elapses.
is_safe_to_unload: Optional predicate; unload only runs when this
returns True (default: always). Use to require IDLE + not auto-paused.
use_glib: If True (default), schedule with GLib.timeout_add_seconds.
If False, only ``fire_if_due`` / manual ``tick`` is available (tests).
"""
def __init__(
self,
get_config: Callable[[], tuple[bool, float]],
on_idle_unload: Optional[Callable[[], None]] = None,
is_safe_to_unload: Optional[Callable[[], bool]] = None,
use_glib: bool = True,
):
self._get_config = get_config
self._on_idle_unload = on_idle_unload
self._is_safe_to_unload = is_safe_to_unload
self._use_glib = use_glib
self._timeout_id: Optional[int] = None
self._running = False
self._armed = False
@property
def active(self) -> bool:
"""True if the keep-alive helper has been started."""
return self._running
@property
def armed(self) -> bool:
"""True while an idle timer is scheduled."""
return self._armed
def start(self) -> None:
"""Mark the helper as running. Safe to call if already started."""
if self._running:
return
self._running = True
logger.info("Model keep-alive started")
def stop(self) -> None:
"""Cancel any pending timer and mark stopped."""
self._running = False
self.cancel()
logger.info("Model keep-alive stopped")
def shutdown(self) -> None:
"""Alias for :meth:`stop` (mirrors SuspendHandler.shutdown)."""
self.stop()
def bump(self) -> None:
"""Reset the idle deadline from now (call when recognition becomes IDLE)."""
if not self._running:
return
enabled, timeout = self._read_config()
if not enabled:
self.cancel()
return
self._schedule(timeout)
def cancel(self) -> None:
"""Cancel any pending idle unload (call while LISTENING/PROCESSING)."""
self._cancel_timeout()
self._armed = False
def fire_if_due(self) -> bool:
"""Manually run the idle unload path (tests / non-GLib). Returns True if fired."""
if not self._running or not self._armed:
return False
return self._fire_unload()
def _read_config(self) -> tuple[bool, float]:
try:
enabled, timeout = self._get_config()
except Exception:
logger.error("Failed to read model keep-alive config", exc_info=True)
return False, float(DEFAULT_IDLE_TIMEOUT_SECONDS)
timeout_f = clamp_idle_timeout(timeout)
return bool(enabled), timeout_f
def _schedule(self, timeout_seconds: float) -> None:
self._cancel_timeout()
self._armed = True
if not self._use_glib:
# Tests drive fire_if_due explicitly; still mark armed.
return
try:
from gi.repository import GLib
except Exception:
logger.warning("GLib unavailable; model keep-alive timer not scheduled")
self._armed = False
return
delay = max(1, int(timeout_seconds))
self._timeout_id = GLib.timeout_add_seconds(delay, self._glib_timeout)
def _cancel_timeout(self) -> None:
if self._timeout_id is None:
return
try:
from gi.repository import GLib
GLib.source_remove(self._timeout_id)
except Exception:
pass
self._timeout_id = None
def _glib_timeout(self) -> bool:
"""GLib source callback: attempt idle unload once."""
self._timeout_id = None
self._fire_unload()
return False # SOURCE_REMOVE — bump() reschedules after next activity
def _fire_unload(self) -> bool:
if not self._running:
self._armed = False
return False
enabled, _timeout = self._read_config()
if not enabled:
self._armed = False
return False
if self._is_safe_to_unload is not None:
try:
if not self._is_safe_to_unload():
logger.debug("Keep-alive unload skipped: not safe to unload")
self._armed = False
return False
except Exception:
logger.error("Keep-alive is_safe_to_unload failed", exc_info=True)
self._armed = False
return False
self._armed = False
logger.info("Model keep-alive idle timeout reached — unloading model")
if self._on_idle_unload:
try:
self._on_idle_unload()
except Exception:
logger.error("Error in keep-alive on_idle_unload callback", exc_info=True)
return False
return True
@@ -816,6 +816,10 @@ class SpeechRecognitionManager:
self._download_cancelled = False
self._defer_download = defer_download
self._model_initialized = False
# True while auto-pause has unloaded the model for a configured app/game
self._auto_paused = False
# True while idle keep-alive has unloaded the model (dictation may lazy-reload)
self._idle_unloaded = False
# Speech detection parameters (load defaults, will be overridden by configure)
self.vad_sensitivity = kwargs.get("vad_sensitivity", 3)
@@ -2282,19 +2286,46 @@ class SpeechRecognitionManager:
logger.warning(f"Cannot start recognition in current state: {self.state}")
return
# Check if model is ready
if not self.model_ready:
if self._auto_paused:
logger.warning(
"Cannot start recognition: model not downloaded. " "Please download via Settings."
"Cannot start recognition: auto-paused while a configured app/game is running"
)
play_error_sound()
_show_notification(
"No Speech Model",
"Please open Settings and download a speech recognition model " "to use dictation.",
"dialog-warning",
"Dictation Paused",
"Vocalinux is paused because a listed game or app is running. "
"Close that app or remove it from Auto-Pause settings to resume.",
"dialog-information",
)
return
# Check if model is ready (lazy-reload after idle keep-alive unload)
if not self.model_ready:
if self._idle_unloaded:
logger.info("Model was unloaded by keep-alive; reloading before dictation")
if not self.ensure_model_loaded():
play_error_sound()
_show_notification(
"Model Reload Failed",
"Vocalinux could not reload the speech model. "
"Open Settings to check your engine and try again.",
"dialog-warning",
)
return
else:
logger.warning(
"Cannot start recognition: model not downloaded. "
"Please download via Settings."
)
play_error_sound()
_show_notification(
"No Speech Model",
"Please open Settings and download a speech recognition model "
"to use dictation.",
"dialog-warning",
)
return
logger.info("Starting speech recognition")
self._update_state(RecognitionState.LISTENING)
@@ -3109,12 +3140,92 @@ class SpeechRecognitionManager:
logger.error(f"Unexpected error during audio reconnection: {e}")
return False
def unload_model(self, reason: str = "auto_pause") -> None:
"""Stop recognition (if active) and release model resources from memory.
Args:
reason: Why the model is being unloaded:
- ``"auto_pause"``: a configured game/app is running; dictation is blocked
until :meth:`reinitialize_after_resume` (or auto-pause resume).
- ``"idle_keepalive"``: idle keep-alive timeout; next dictation may
lazy-reload via :meth:`ensure_model_loaded`.
- ``"manual"`` / other: unload only without setting pause/idle flags.
"""
logger.info("Unloading speech model (reason=%s)", reason)
if self.state != RecognitionState.IDLE:
logger.info("Stopping active recognition before model unload")
self.stop_recognition()
with self._model_lock:
self.model = None
self.recognizer = None
if self._http_session is not None:
try:
self._http_session.close()
except Exception:
logger.debug("Error closing HTTP session during unload", exc_info=True)
self._http_session = None
self._model_initialized = False
if reason == "auto_pause":
self._auto_paused = True
self._idle_unloaded = False
logger.info("Speech model unloaded; dictation blocked until auto-pause clears")
elif reason == "idle_keepalive":
# Auto-pause owns lifecycle if both could apply
if not self._auto_paused:
self._idle_unloaded = True
logger.info("Speech model unloaded after idle keep-alive timeout")
else:
logger.info("Speech model unloaded")
try:
import gc
gc.collect()
except Exception:
pass
@property
def is_auto_paused(self) -> bool:
"""True when the model was unloaded for auto-pause and not yet reloaded."""
return bool(self._auto_paused)
@property
def is_idle_unloaded(self) -> bool:
"""True when the model was unloaded by idle keep-alive and not yet reloaded."""
return bool(self._idle_unloaded)
def ensure_model_loaded(self) -> bool:
"""Ensure the speech model is loaded and ready for dictation.
Returns:
True if the model is ready after this call. Returns False when
auto-pause still owns the unload, or when reload fails.
"""
if self.model_ready:
return True
if self._auto_paused:
logger.warning("Cannot load model: auto-pause is still active")
return False
try:
self.reinitialize_after_resume()
except Exception:
logger.error("ensure_model_loaded failed", exc_info=True)
return False
return self.model_ready
def reinitialize_after_resume(self):
"""Reinitialize the speech engine after system resume from suspend.
Stops any active recognition, releases stale model resources,
and re-creates the engine so that the audio pipeline and model
are in a clean state for new dictation.
Also used after auto-pause / idle keep-alive to reload the model.
"""
logger.info("Reinitializing speech engine after system resume")
@@ -3143,6 +3254,8 @@ class SpeechRecognitionManager:
logger.error("Cannot reinitialize: unknown engine '%s'", self.engine)
return
self._auto_paused = False
self._idle_unloaded = False
logger.info("Speech engine reinitialized after resume")
except Exception:
logger.error("Failed to reinitialize speech engine after resume", exc_info=True)
+13
View File
@@ -59,6 +59,19 @@ DEFAULT_CONFIG = {
"autostart": False,
"first_run": True,
},
"auto_pause": {
# When enabled, unload the speech model while any listed process is running
# so games/apps can use full CPU/GPU/RAM. Model reloads when they exit.
"enabled": False,
"apps": [], # Process/executable basenames, e.g. ["overwatch", "steam"]
"poll_interval_seconds": 5, # How often to scan running processes
},
"model_keepalive": {
# Opt-in idle unload so hybrid-GPU laptops can release VRAM/Vulkan and
# let the dGPU sleep. Next dictation lazy-reloads the model (cold start).
"enabled": False,
"idle_timeout_seconds": 300, # 5 minutes when enabled
},
"text_injection": {
"copy_to_clipboard": False, # Disabled by default; users can enable in Settings
},
+385 -3
View File
@@ -346,9 +346,11 @@ SETTINGS_CSS = """
background-color: alpha(@theme_selected_bg_color, 0.2);
}
.notebook tab:selected {
/* GTK3 marks the selected notebook tab with :checked (not :selected) */
.notebook tab:checked {
background-color: transparent;
color: @theme_fg_color;
box-shadow: inset 0 -3px @theme_selected_bg_color;
}
.notebook tab label {
@@ -356,8 +358,8 @@ SETTINGS_CSS = """
font-size: 0.95em;
}
.notebook tab:selected label {
font-weight: 600;
.notebook tab:checked label {
font-weight: 700;
}
.notebook header {
@@ -1077,6 +1079,12 @@ class SettingsDialog(Gtk.Dialog):
self.shortcuts_tab.set_margin_start(16)
self.shortcuts_tab.set_margin_end(16)
self.power_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
self.power_tab.set_margin_top(16)
self.power_tab.set_margin_bottom(16)
self.power_tab.set_margin_start(16)
self.power_tab.set_margin_end(16)
self.general_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
self.general_tab.set_margin_top(16)
self.general_tab.set_margin_bottom(16)
@@ -1120,6 +1128,11 @@ class SettingsDialog(Gtk.Dialog):
shortcuts_label.set_tooltip_text("Keyboard shortcuts")
notebook.append_page(_scrollable(self.shortcuts_tab), shortcuts_label)
# Power tab - resource management (auto-pause, idle unload)
power_label = Gtk.Label(label="Power")
power_label.set_tooltip_text("Free CPU, GPU, and memory while gaming or idle")
notebook.append_page(_scrollable(self.power_tab), power_label)
# General tab - least important (application behavior)
general_label = Gtk.Label(label="General")
general_label.set_tooltip_text("General settings")
@@ -1142,6 +1155,8 @@ class SettingsDialog(Gtk.Dialog):
# Build UI sections into appropriate tabs
self._build_general_section()
self._build_auto_pause_section()
self._build_model_keepalive_section()
self._build_audio_section()
self._build_engine_section()
self._build_remote_server_section()
@@ -1287,6 +1302,359 @@ class SettingsDialog(Gtk.Dialog):
self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled)
self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
def _build_auto_pause_section(self):
"""Build Auto-Pause settings: enable toggle + process name list."""
group = PreferencesGroup(
title="Auto-Pause for Games & Apps",
description=(
"While any app in the list is running, Vocalinux pauses dictation and "
"unloads the speech model to free CPU, GPU, and memory. Everything "
"resumes automatically when the app closes."
),
)
self.auto_pause_switch = Gtk.Switch()
self.auto_pause_switch.set_tooltip_text(
"Pause dictation and unload the speech model while any listed app is running"
)
enable_row = PreferenceRow(
title="Pause for Listed Apps",
subtitle="Free resources while any app below is running",
widget=self.auto_pause_switch,
)
group.add_row(enable_row)
# Add process name: entry + Add button
add_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
add_box.set_margin_top(8)
add_box.set_margin_bottom(4)
add_box.set_margin_start(16)
add_box.set_margin_end(16)
self.auto_pause_entry = Gtk.Entry()
self.auto_pause_entry.set_placeholder_text("Process name (e.g. overwatch, steam)")
self.auto_pause_entry.set_hexpand(True)
self.auto_pause_entry.set_tooltip_text(
"Process name, matched case-insensitively. "
"For Windows games under Wine/Proton, the .exe suffix is optional."
)
add_box.pack_start(self.auto_pause_entry, True, True, 0)
self.auto_pause_add_btn = Gtk.Button(label="Add")
self.auto_pause_add_btn.set_tooltip_text("Add this process name to the list")
self.auto_pause_add_btn.connect("clicked", self._on_auto_pause_add_clicked)
self.auto_pause_entry.connect("activate", self._on_auto_pause_add_clicked)
add_box.pack_start(self.auto_pause_add_btn, False, False, 0)
self.auto_pause_pick_btn = Gtk.Button(label="Choose Running App…")
self.auto_pause_pick_btn.set_tooltip_text("Pick from apps that are running right now")
self.auto_pause_pick_btn.connect("clicked", self._on_auto_pause_pick_running)
add_box.pack_start(self.auto_pause_pick_btn, False, False, 0)
# Custom row for the add controls (not a PreferenceRow)
add_row = Gtk.ListBoxRow()
add_row.set_activatable(False)
add_row.add(add_box)
group.add_row(add_row)
# List of configured apps
self.auto_pause_listbox = Gtk.ListBox()
self.auto_pause_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.auto_pause_listbox.set_activate_on_single_click(False)
list_scrolled = Gtk.ScrolledWindow()
list_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
list_scrolled.set_min_content_height(80)
list_scrolled.set_max_content_height(160)
list_scrolled.set_margin_start(8)
list_scrolled.set_margin_end(8)
list_scrolled.set_margin_bottom(8)
list_scrolled.add(self.auto_pause_listbox)
list_row = Gtk.ListBoxRow()
list_row.set_activatable(False)
list_row.add(list_scrolled)
group.add_row(list_row)
self.auto_pause_empty_label = Gtk.Label(
label="No apps in the list yet. Type a process name or choose a running app above.",
xalign=0,
)
self.auto_pause_empty_label.get_style_context().add_class("preference-row-subtitle")
self.auto_pause_empty_label.set_margin_start(16)
self.auto_pause_empty_label.set_margin_end(16)
self.auto_pause_empty_label.set_margin_bottom(12)
empty_row = Gtk.ListBoxRow()
empty_row.set_activatable(False)
empty_row.add(self.auto_pause_empty_label)
group.add_row(empty_row)
# Keep the dialog-wide show_all() from re-showing this row when the
# list is non-empty; visibility is managed in _refresh_auto_pause_list.
empty_row.set_no_show_all(True)
self._auto_pause_empty_row = empty_row
self.power_tab.pack_start(group, False, False, 0)
self.auto_pause_switch.connect("state-set", self._on_auto_pause_enabled_toggled)
def _update_auto_pause_sensitivity(self, enabled: bool) -> None:
"""Gray out the app-list controls while auto-pause is disabled."""
for widget in (
self.auto_pause_entry,
self.auto_pause_add_btn,
self.auto_pause_pick_btn,
self.auto_pause_listbox,
):
widget.set_sensitive(enabled)
def _get_auto_pause_apps(self) -> list:
"""Return a clean list of configured auto-pause process names."""
apps = self.config_manager.get("auto_pause", "apps", []) or []
if not isinstance(apps, list):
return []
return [str(a).strip() for a in apps if a and str(a).strip()]
def _save_auto_pause_apps(self, apps: list) -> None:
# Deduplicate case-insensitively while preserving first-seen casing
seen = set()
cleaned = []
for name in apps:
key = name.strip().lower()
if not key or key in seen:
continue
seen.add(key)
cleaned.append(name.strip())
self.config_manager.set("auto_pause", "apps", cleaned)
self.config_manager.save_settings()
self._refresh_auto_pause_list()
def _refresh_auto_pause_list(self) -> None:
"""Rebuild the auto-pause app list UI from config."""
if not hasattr(self, "auto_pause_listbox"):
return
for child in list(self.auto_pause_listbox.get_children()):
self.auto_pause_listbox.remove(child)
apps = self._get_auto_pause_apps()
if hasattr(self, "_auto_pause_empty_row"):
if len(apps) == 0:
self.auto_pause_empty_label.show()
self._auto_pause_empty_row.show()
else:
self._auto_pause_empty_row.hide()
for name in apps:
row = Gtk.ListBoxRow()
row.set_activatable(False)
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
hbox.set_margin_top(6)
hbox.set_margin_bottom(6)
hbox.set_margin_start(16)
hbox.set_margin_end(16)
label = Gtk.Label(label=name, xalign=0)
label.set_hexpand(True)
hbox.pack_start(label, True, True, 0)
remove_btn = Gtk.Button(label="Remove")
remove_btn.set_tooltip_text(f"Remove {name} from auto-pause list")
remove_btn.connect("clicked", self._on_auto_pause_remove_clicked, name)
hbox.pack_start(remove_btn, False, False, 0)
row.add(hbox)
self.auto_pause_listbox.add(row)
self.auto_pause_listbox.show_all()
def _on_auto_pause_enabled_toggled(self, widget, state):
enabled = bool(state)
self._update_auto_pause_sensitivity(enabled)
if self._initializing or self._applying_settings:
return False
logger.info("Auto-pause enabled toggled: %s", enabled)
self.config_manager.set("auto_pause", "enabled", enabled)
self.config_manager.save_settings()
return False
def _on_auto_pause_add_clicked(self, widget):
if self._initializing or self._applying_settings:
return
name = self.auto_pause_entry.get_text().strip()
if not name:
return
# Strip path if user pasted a full path
name = os.path.basename(name)
if name.lower().endswith(".exe"):
name = name[:-4]
apps = self._get_auto_pause_apps()
if name.lower() in {a.lower() for a in apps}:
self.auto_pause_entry.set_text("")
return
apps.append(name)
self._save_auto_pause_apps(apps)
self.auto_pause_entry.set_text("")
logger.info("Added auto-pause app: %s", name)
def _on_auto_pause_remove_clicked(self, widget, name: str):
if self._initializing or self._applying_settings:
return
apps = [a for a in self._get_auto_pause_apps() if a.lower() != name.lower()]
self._save_auto_pause_apps(apps)
logger.info("Removed auto-pause app: %s", name)
def _on_auto_pause_pick_running(self, widget):
"""Show a simple dialog listing running process names to add."""
if self._initializing or self._applying_settings:
return
try:
import psutil
except ImportError:
logger.warning("psutil not available for process picker")
return
names: set[str] = set()
for proc in psutil.process_iter(["name"]):
try:
n = proc.info.get("name")
if n:
base = os.path.basename(n)
if base.lower().endswith(".exe"):
base = base[:-4]
names.add(base)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
if not names:
return
dialog = Gtk.Dialog(title="Choose a Running App", transient_for=self, flags=0)
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("Add", Gtk.ResponseType.OK)
dialog.set_default_size(360, 400)
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_margin_top(8)
scrolled.set_margin_bottom(8)
scrolled.set_margin_start(8)
scrolled.set_margin_end(8)
listbox = Gtk.ListBox()
listbox.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
already = {a.lower() for a in self._get_auto_pause_apps()}
for name in sorted(names, key=str.lower):
if name.lower() in already:
continue
row = Gtk.ListBoxRow()
label = Gtk.Label(label=name, xalign=0)
label.set_margin_start(8)
label.set_margin_end(8)
label.set_margin_top(4)
label.set_margin_bottom(4)
row.add(label)
listbox.add(row)
scrolled.add(listbox)
dialog.get_content_area().pack_start(scrolled, True, True, 0)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
selected = listbox.get_selected_rows()
apps = self._get_auto_pause_apps()
existing = {a.lower() for a in apps}
for row in selected:
label = row.get_child()
if isinstance(label, Gtk.Label):
name = label.get_text().strip()
if name and name.lower() not in existing:
apps.append(name)
existing.add(name.lower())
self._save_auto_pause_apps(apps)
dialog.destroy()
def _build_model_keepalive_section(self):
"""Build idle unload settings: enable toggle + idle timeout."""
group = PreferencesGroup(
title="Unload When Idle",
description=(
"After a period of inactivity, unload the speech model to save memory "
"and battery — on hybrid-GPU laptops (NVIDIA Optimus) this lets the "
"GPU sleep. The next dictation reloads the model automatically, which "
"may take a moment on larger models."
),
)
self.model_keepalive_switch = Gtk.Switch()
self.model_keepalive_switch.set_tooltip_text(
"Unload the speech model after the idle timeout to save memory and battery"
)
enable_row = PreferenceRow(
title="Unload Model When Idle",
subtitle="Free memory and GPU power after a period of inactivity",
widget=self.model_keepalive_switch,
)
group.add_row(enable_row)
self.model_keepalive_timeout_combo = Gtk.ComboBoxText()
self.model_keepalive_timeout_combo.set_size_request(160, -1)
# id = seconds as string
for seconds, label in (
(60, "1 minute"),
(300, "5 minutes"),
(600, "10 minutes"),
(900, "15 minutes"),
(1800, "30 minutes"),
):
self.model_keepalive_timeout_combo.append(str(seconds), label)
self.model_keepalive_timeout_combo.set_tooltip_text(
"How long to wait after the last dictation before unloading the model"
)
_prevent_scroll_on_hover(self.model_keepalive_timeout_combo)
timeout_row = PreferenceRow(
title="Idle Timeout",
subtitle="Unload the model after this much inactivity",
widget=self.model_keepalive_timeout_combo,
)
group.add_row(timeout_row)
self.power_tab.pack_start(group, False, False, 0)
self.model_keepalive_switch.connect("state-set", self._on_model_keepalive_enabled_toggled)
self.model_keepalive_timeout_combo.connect(
"changed", self._on_model_keepalive_timeout_changed
)
def _update_model_keepalive_sensitivity(self, enabled: bool) -> None:
"""Gray out the idle timeout selector while idle unload is disabled."""
self.model_keepalive_timeout_combo.set_sensitive(enabled)
def _on_model_keepalive_enabled_toggled(self, widget, state):
enabled = bool(state)
self._update_model_keepalive_sensitivity(enabled)
if self._initializing or self._applying_settings:
return False
logger.info("Model keep-alive enabled toggled: %s", enabled)
self.config_manager.set("model_keepalive", "enabled", enabled)
self.config_manager.save_settings()
return False
def _on_model_keepalive_timeout_changed(self, widget):
if self._initializing or self._applying_settings:
return
active_id = self.model_keepalive_timeout_combo.get_active_id()
if not active_id:
return
try:
seconds = int(active_id)
except (TypeError, ValueError):
return
logger.info("Model keep-alive timeout set to %s seconds", seconds)
self.config_manager.set("model_keepalive", "idle_timeout_seconds", seconds)
self.config_manager.save_settings()
def _on_autostart_toggled(self, widget, state):
"""Handle toggle of the autostart switch."""
if self._initializing or self._applying_settings:
@@ -2516,6 +2884,20 @@ class SettingsDialog(Gtk.Dialog):
self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
self.sound_effects_switch.set_active(self.config_manager.is_sound_effects_enabled())
auto_pause_settings = self.config_manager.get_settings().get("auto_pause", {})
auto_pause_enabled = bool(auto_pause_settings.get("enabled", False))
self.auto_pause_switch.set_active(auto_pause_enabled)
self._update_auto_pause_sensitivity(auto_pause_enabled)
self._refresh_auto_pause_list()
keepalive_settings = self.config_manager.get_settings().get("model_keepalive", {})
keepalive_enabled = bool(keepalive_settings.get("enabled", False))
self.model_keepalive_switch.set_active(keepalive_enabled)
self._update_model_keepalive_sensitivity(keepalive_enabled)
timeout_seconds = int(keepalive_settings.get("idle_timeout_seconds", 300) or 300)
if not self.model_keepalive_timeout_combo.set_active_id(str(timeout_seconds)):
self.model_keepalive_timeout_combo.set_active_id("300")
available_engines = get_available_engines()
available_count = 0
+112 -1
View File
@@ -28,7 +28,9 @@ except (ImportError, ValueError):
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.resource_manager import ResourceManager
from .config_manager import ConfigManager
@@ -130,6 +132,23 @@ class TrayIndicator:
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()
# Set up keyboard shortcuts with mode support
self._setup_keyboard_shortcuts()
@@ -423,6 +442,13 @@ class TrayIndicator:
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)
@@ -560,8 +586,77 @@ class TrayIndicator:
logger.debug("About clicked")
show_about_dialog(parent=None)
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()
@@ -572,9 +667,16 @@ class TrayIndicator:
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")
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume)
# 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):
@@ -582,6 +684,9 @@ class TrayIndicator:
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):
@@ -652,6 +757,12 @@ class TrayIndicator:
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()
self._cleanup_input_monitor()
# Stop the keyboard shortcut manager