Files
voculinux/src/vocalinux/model_keepalive.py
T
4c070ded46 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>
2026-07-26 22:29:00 -07:00

186 lines
6.0 KiB
Python

"""
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