fix(suspend): auto-recover speech recognition after system resume (#369)
* feat(suspend): add D-Bus based suspend/resume handler Monitors org.freedesktop.login1.Manager.PrepareForSleep via Gio.DBusProxy to detect system suspend/resume. On resume, invokes a callback to reinitialize the audio pipeline and speech model so dictation works immediately without manual intervention. Fixes #367 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(suspend): wire suspend handler into tray indicator and recognition manager TrayIndicator now creates a SuspendHandler on startup that listens for D-Bus PrepareForSleep signals. On suspend, active recognition is stopped. On resume, a 2-second delay allows audio hardware to stabilize before reinitializing the speech engine and resetting audio reconnection state. Fixes #367 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
f969a9b74f
commit
80536c0fa4
@@ -2211,6 +2211,42 @@ class SpeechRecognitionManager:
|
||||
logger.error(f"Unexpected error during audio reconnection: {e}")
|
||||
return False
|
||||
|
||||
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.
|
||||
"""
|
||||
logger.info("Reinitializing speech engine after system resume")
|
||||
|
||||
if self.state != RecognitionState.IDLE:
|
||||
logger.info("Stopping active recognition before resume reinit")
|
||||
self.stop_recognition()
|
||||
|
||||
with self._model_lock:
|
||||
self.model = None
|
||||
self.recognizer = None
|
||||
self._model_initialized = False
|
||||
|
||||
try:
|
||||
if self.engine == "vosk":
|
||||
self._init_vosk()
|
||||
elif self.engine == "whisper":
|
||||
self._init_whisper()
|
||||
elif self.engine == "whisper_cpp":
|
||||
self._init_whispercpp()
|
||||
else:
|
||||
logger.error("Cannot reinitialize: unknown engine '%s'", self.engine)
|
||||
return
|
||||
|
||||
logger.info("Speech engine reinitialized after resume")
|
||||
except Exception:
|
||||
logger.error("Failed to reinitialize speech engine after resume", exc_info=True)
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
|
||||
self._reconnection_attempts = 0
|
||||
|
||||
def set_buffer_limit(self, max_chunks: int):
|
||||
"""
|
||||
Set the maximum number of audio chunks to buffer.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Suspend/resume handler for Vocalinux.
|
||||
|
||||
Monitors systemd-logind via D-Bus for PrepareForSleep signals and
|
||||
automatically reinitializes the audio pipeline and speech model
|
||||
after the system resumes from suspend or hibernation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOGIND_BUS_NAME = "org.freedesktop.login1"
|
||||
_LOGIND_OBJECT_PATH = "/org/freedesktop/login1"
|
||||
_LOGIND_INTERFACE = "org.freedesktop.login1.Manager"
|
||||
_PREPARE_FOR_SLEEP_SIGNAL = "PrepareForSleep"
|
||||
|
||||
|
||||
class SuspendHandler:
|
||||
"""Handles system suspend/resume events via D-Bus.
|
||||
|
||||
Subscribes to org.freedesktop.login1.Manager.PrepareForSleep and
|
||||
invokes callbacks when the system is about to suspend or has just
|
||||
resumed. All callbacks run on the GLib main loop thread so GTK
|
||||
operations are safe.
|
||||
|
||||
Usage::
|
||||
|
||||
handler = SuspendHandler(
|
||||
on_suspend=my_stop_fn,
|
||||
on_resume=my_resume_fn,
|
||||
)
|
||||
# ... later, on shutdown:
|
||||
handler.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_suspend: Optional[Callable[[], None]] = None,
|
||||
on_resume: Optional[Callable[[], None]] = None,
|
||||
):
|
||||
self._on_suspend = on_suspend
|
||||
self._on_resume = on_resume
|
||||
self._proxy: Optional[Gio.DBusProxy] = None
|
||||
self._was_suspended = False
|
||||
self._connect()
|
||||
|
||||
def _connect(self) -> None:
|
||||
"""Create a D-Bus proxy and subscribe to PrepareForSleep."""
|
||||
try:
|
||||
self._proxy = Gio.DBusProxy.new_for_bus_sync(
|
||||
bus_type=Gio.BusType.SYSTEM,
|
||||
flags=Gio.DBusProxyFlags.NONE,
|
||||
info=None,
|
||||
name=_LOGIND_BUS_NAME,
|
||||
object_path=_LOGIND_OBJECT_PATH,
|
||||
interface_name=_LOGIND_INTERFACE,
|
||||
cancellable=None,
|
||||
)
|
||||
self._proxy.connect("g-signal", self._on_signal)
|
||||
logger.info("Suspend/resume handler initialized (logind D-Bus)")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Unexpected error connecting to logind D-Bus: %s",
|
||||
exc,
|
||||
)
|
||||
self._proxy = None
|
||||
|
||||
def _on_signal(
|
||||
self,
|
||||
proxy: Gio.DBusProxy,
|
||||
sender_name: str,
|
||||
signal_name: str,
|
||||
parameters: GLib.Variant,
|
||||
) -> None:
|
||||
"""Handle incoming D-Bus signals from logind."""
|
||||
if signal_name != _PREPARE_FOR_SLEEP_SIGNAL:
|
||||
return
|
||||
|
||||
# The parameter is a tuple (b entering_sleep,)
|
||||
if parameters.get_type_string() != "(b)":
|
||||
logger.warning(
|
||||
"Unexpected PrepareForSleep parameter type: %s",
|
||||
parameters.get_type_string(),
|
||||
)
|
||||
return
|
||||
|
||||
entering_sleep = parameters.unpack()[0]
|
||||
|
||||
if entering_sleep:
|
||||
logger.info("System preparing to suspend")
|
||||
self._was_suspended = True
|
||||
if self._on_suspend:
|
||||
try:
|
||||
self._on_suspend()
|
||||
except Exception:
|
||||
logger.error("Error in suspend callback", exc_info=True)
|
||||
else:
|
||||
logger.info("System resuming from suspend")
|
||||
# Also handles the edge-case where we missed the
|
||||
# PrepareForSleep(true) (e.g. hibernation resume —
|
||||
# see systemd issue #30666).
|
||||
self._was_suspended = False
|
||||
if self._on_resume:
|
||||
try:
|
||||
self._on_resume()
|
||||
except Exception:
|
||||
logger.error("Error in resume callback", exc_info=True)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Disconnect from D-Bus. Call from GTK destroy / quit handler."""
|
||||
if self._proxy is not None:
|
||||
try:
|
||||
self._proxy.disconnect_by_func(self._on_signal)
|
||||
except Exception:
|
||||
pass
|
||||
self._proxy = None
|
||||
logger.info("Suspend/resume handler shut down")
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Return True if the D-Bus proxy is connected."""
|
||||
return self._proxy is not None
|
||||
@@ -29,6 +29,7 @@ from gi.repository import GdkPixbuf, GLib, GObject, Gtk
|
||||
|
||||
# Import local modules - Use protocols to avoid circular imports
|
||||
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
|
||||
from ..suspend_handler import SuspendHandler # noqa: E402
|
||||
|
||||
# Import necessary components
|
||||
from .config_manager import ConfigManager # noqa: E402
|
||||
@@ -111,6 +112,11 @@ class TrayIndicator:
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Set up keyboard shortcuts with mode support
|
||||
self._setup_keyboard_shortcuts()
|
||||
|
||||
@@ -450,6 +456,28 @@ class TrayIndicator:
|
||||
logger.debug("About clicked")
|
||||
show_about_dialog(parent=None)
|
||||
|
||||
def _on_system_suspend(self):
|
||||
"""Stop active recognition before the system goes to sleep."""
|
||||
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 the speech engine after the system wakes up.
|
||||
|
||||
Schedules the actual work with a short delay so the audio
|
||||
subsystem has time to come back online.
|
||||
"""
|
||||
logger.info("System resumed — scheduling speech engine reinit")
|
||||
GLib.timeout_add_seconds(2, self._reinit_after_resume)
|
||||
|
||||
def _reinit_after_resume(self):
|
||||
try:
|
||||
self.speech_engine.reinitialize_after_resume()
|
||||
except Exception:
|
||||
logger.error("Failed to reinitialize after resume", exc_info=True)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def _on_quit_clicked(self, widget):
|
||||
"""Handle click on the Quit menu item."""
|
||||
logger.debug("Quit clicked")
|
||||
@@ -459,6 +487,9 @@ class TrayIndicator:
|
||||
"""Quit the application."""
|
||||
logger.info("Quitting application")
|
||||
|
||||
if self._suspend_handler is not None:
|
||||
self._suspend_handler.shutdown()
|
||||
|
||||
# Stop the keyboard shortcut manager
|
||||
self.shortcut_manager.stop()
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Tests for suspend/resume handling.
|
||||
|
||||
Covers SuspendHandler and the reinitialize_after_resume path in
|
||||
SpeechRecognitionManager.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
mock_pywhispercpp = MagicMock()
|
||||
mock_pywhispercpp.model = MagicMock()
|
||||
mock_pywhispercpp.model.Model = MagicMock()
|
||||
|
||||
sys.modules["vosk"] = MagicMock()
|
||||
sys.modules["whisper"] = MagicMock()
|
||||
sys.modules["pywhispercpp"] = mock_pywhispercpp
|
||||
sys.modules["pywhispercpp.model"] = mock_pywhispercpp.model
|
||||
sys.modules["requests"] = MagicMock()
|
||||
sys.modules["pyaudio"] = MagicMock()
|
||||
sys.modules["wave"] = MagicMock()
|
||||
sys.modules["tqdm"] = MagicMock()
|
||||
sys.modules["numpy"] = MagicMock()
|
||||
sys.modules["torch"] = MagicMock()
|
||||
sys.modules["psutil"] = MagicMock()
|
||||
|
||||
from conftest import mock_audio_feedback # noqa: E402
|
||||
|
||||
from vocalinux.common_types import RecognitionState # noqa: E402
|
||||
from vocalinux.suspend_handler import SuspendHandler # noqa: E402
|
||||
|
||||
|
||||
class TestSuspendHandlerInit(unittest.TestCase):
|
||||
"""Tests for SuspendHandler initialization."""
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_init_connects_to_logind(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
handler = SuspendHandler()
|
||||
|
||||
mock_gio.DBusProxy.new_for_bus_sync.assert_called_once_with(
|
||||
bus_type=mock_gio.BusType.SYSTEM,
|
||||
flags=mock_gio.DBusProxyFlags.NONE,
|
||||
info=None,
|
||||
name="org.freedesktop.login1",
|
||||
object_path="/org/freedesktop/login1",
|
||||
interface_name="org.freedesktop.login1.Manager",
|
||||
cancellable=None,
|
||||
)
|
||||
mock_proxy.connect.assert_called_once_with("g-signal", handler._on_signal)
|
||||
assert handler.active is True
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_init_handles_dbus_unavailable(self, mock_gio):
|
||||
mock_gio.DBusProxy.new_for_bus_sync.side_effect = Exception("no logind")
|
||||
|
||||
handler = SuspendHandler()
|
||||
|
||||
assert handler.active is False
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_shutdown_disconnects(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
handler = SuspendHandler()
|
||||
handler.shutdown()
|
||||
|
||||
mock_proxy.disconnect_by_func.assert_called_once_with(handler._on_signal)
|
||||
assert handler.active is False
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_shutdown_when_not_connected(self, mock_gio):
|
||||
from gi.repository import GLib
|
||||
|
||||
mock_gio.DBusProxy.new_for_bus_sync.side_effect = GLib.Error("no logind")
|
||||
|
||||
handler = SuspendHandler()
|
||||
handler.shutdown()
|
||||
|
||||
|
||||
class TestSuspendHandlerCallbacks(unittest.TestCase):
|
||||
"""Tests for suspend/resume callback invocation."""
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_suspend_callback_invoked(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock()
|
||||
resume_cb = MagicMock()
|
||||
handler = SuspendHandler(on_suspend=suspend_cb, on_resume=resume_cb)
|
||||
|
||||
# Simulate PrepareForSleep(true)
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(b)"
|
||||
mock_params.unpack.return_value = (True,)
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
suspend_cb.assert_called_once()
|
||||
resume_cb.assert_not_called()
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_resume_callback_invoked(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock()
|
||||
resume_cb = MagicMock()
|
||||
handler = SuspendHandler(on_suspend=suspend_cb, on_resume=resume_cb)
|
||||
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(b)"
|
||||
mock_params.unpack.return_value = (True,)
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_resume_callback_invoked(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock()
|
||||
resume_cb = MagicMock()
|
||||
handler = SuspendHandler(on_suspend=suspend_cb, on_resume=resume_cb)
|
||||
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(b)"
|
||||
mock_params.unpack.return_value = (False,)
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
resume_cb.assert_called_once()
|
||||
suspend_cb.assert_not_called()
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_ignores_other_signals(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock()
|
||||
resume_cb = MagicMock()
|
||||
handler = SuspendHandler(on_suspend=suspend_cb, on_resume=resume_cb)
|
||||
|
||||
mock_params = MagicMock()
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "SomeOtherSignal", mock_params)
|
||||
|
||||
suspend_cb.assert_not_called()
|
||||
resume_cb.assert_not_called()
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_ignores_unexpected_param_type(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock()
|
||||
resume_cb = MagicMock()
|
||||
handler = SuspendHandler(on_suspend=suspend_cb, on_resume=resume_cb)
|
||||
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(s)"
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
suspend_cb.assert_not_called()
|
||||
resume_cb.assert_not_called()
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_callback_exception_is_caught(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
suspend_cb = MagicMock(side_effect=RuntimeError("boom"))
|
||||
handler = SuspendHandler(on_suspend=suspend_cb)
|
||||
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(b)"
|
||||
mock_params.unpack.return_value = (True,)
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
@patch("vocalinux.suspend_handler.Gio")
|
||||
def test_no_callback_does_not_crash(self, mock_gio):
|
||||
mock_proxy = MagicMock()
|
||||
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
|
||||
|
||||
handler = SuspendHandler()
|
||||
|
||||
mock_params = MagicMock()
|
||||
mock_params.get_type_string.return_value = "(b)"
|
||||
mock_params.unpack.return_value = (True,)
|
||||
|
||||
handler._on_signal(mock_proxy, "org.freedesktop.login1", "PrepareForSleep", mock_params)
|
||||
|
||||
|
||||
class TestReinitializeAfterResume(unittest.TestCase):
|
||||
"""Tests for SpeechRecognitionManager.reinitialize_after_resume."""
|
||||
|
||||
def _make_manager(self):
|
||||
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||
|
||||
with patch.object(SpeechRecognitionManager, "__init__", lambda self: None):
|
||||
mgr = SpeechRecognitionManager()
|
||||
|
||||
mgr.engine = "vosk"
|
||||
mgr.state = RecognitionState.IDLE
|
||||
mgr.model = MagicMock()
|
||||
mgr.recognizer = MagicMock()
|
||||
mgr._model_initialized = True
|
||||
mgr._model_lock = MagicMock()
|
||||
mgr._model_lock.__enter__ = MagicMock(return_value=None)
|
||||
mgr._model_lock.__exit__ = MagicMock(return_value=False)
|
||||
mgr._reconnection_attempts = 3
|
||||
mgr._update_state = MagicMock()
|
||||
mgr._init_vosk = MagicMock()
|
||||
mgr._init_whisper = MagicMock()
|
||||
mgr._init_whispercpp = MagicMock()
|
||||
mgr.stop_recognition = MagicMock()
|
||||
return mgr
|
||||
|
||||
def test_reinitializes_vosk_engine(self):
|
||||
mgr = self._make_manager()
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
mgr._init_vosk.assert_called_once()
|
||||
assert mgr._reconnection_attempts == 0
|
||||
|
||||
def test_reinitializes_whisper_engine(self):
|
||||
mgr = self._make_manager()
|
||||
mgr.engine = "whisper"
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
mgr._init_whisper.assert_called_once()
|
||||
|
||||
def test_reinitializes_whispercpp_engine(self):
|
||||
mgr = self._make_manager()
|
||||
mgr.engine = "whisper_cpp"
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
mgr._init_whispercpp.assert_called_once()
|
||||
|
||||
def test_stops_active_recognition_first(self):
|
||||
mgr = self._make_manager()
|
||||
mgr.state = RecognitionState.LISTENING
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
mgr.stop_recognition.assert_called_once()
|
||||
|
||||
def test_clears_model_state(self):
|
||||
mgr = self._make_manager()
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
assert mgr.model is None
|
||||
assert mgr.recognizer is None
|
||||
assert mgr._model_initialized is False
|
||||
|
||||
def test_sets_error_state_on_failure(self):
|
||||
mgr = self._make_manager()
|
||||
mgr._init_vosk.side_effect = RuntimeError("model load failed")
|
||||
|
||||
mgr.reinitialize_after_resume()
|
||||
|
||||
mgr._update_state.assert_called_once_with(RecognitionState.ERROR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user