diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index af68d70..8428084 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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. diff --git a/src/vocalinux/suspend_handler.py b/src/vocalinux/suspend_handler.py new file mode 100644 index 0000000..e8aac43 --- /dev/null +++ b/src/vocalinux/suspend_handler.py @@ -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 diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 1611cb0..934e7f8 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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() diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py new file mode 100644 index 0000000..92119d5 --- /dev/null +++ b/tests/test_suspend_handler.py @@ -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()