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:
Jatin K Malik
2026-03-29 22:41:21 -07:00
committed by GitHub
co-authored by Sisyphus
parent f969a9b74f
commit 80536c0fa4
4 changed files with 469 additions and 0 deletions
@@ -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.
+126
View File
@@ -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
+31
View File
@@ -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()
+276
View File
@@ -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()