fix(tray): show the tray before the speech model loads (#51)
The first model load runs on a background thread after the tray is up. While it loads the tray shows the processing icon and 'Loading model…', and a dictation request gets a 'still loading' notification. A failed load leaves the tray in the error state instead of exiting. Fixes #50
This commit was merged in pull request #51.
This commit is contained in:
@@ -621,6 +621,8 @@ def main():
|
|||||||
remote_api_key=saved_settings.get("remote_api_key", ""),
|
remote_api_key=saved_settings.get("remote_api_key", ""),
|
||||||
remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"),
|
remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"),
|
||||||
remote_api_model=saved_settings.get("remote_api_model", "whisper-1"),
|
remote_api_model=saved_settings.get("remote_api_model", "whisper-1"),
|
||||||
|
# The model loads in the background so the tray can appear first (#50).
|
||||||
|
defer_model_load=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize text injection system
|
# Initialize text injection system
|
||||||
@@ -1382,6 +1384,11 @@ def main():
|
|||||||
dictation_pad=dictation_pad,
|
dictation_pad=dictation_pad,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start the model load from the GTK main loop, after the tray's own idle
|
||||||
|
# callback has put the icon up. The load runs on its own thread, so the
|
||||||
|
# tray is usable while it loads (#50).
|
||||||
|
GLib.idle_add(speech_engine.load_model_in_background)
|
||||||
|
|
||||||
# Start the GTK main loop
|
# Start the GTK main loop
|
||||||
indicator.run()
|
indicator.run()
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ def resolve_language_preference(language: str) -> str:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Engine names the constructor accepts; checked up front when the load is deferred (#50).
|
||||||
|
_SUPPORTED_ENGINES = ("vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api")
|
||||||
|
|
||||||
_WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {}
|
_WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -570,6 +573,12 @@ class SpeechRecognitionManager:
|
|||||||
# cancel-and-retry cannot stack unaccounted request threads.
|
# cancel-and-retry cannot stack unaccounted request threads.
|
||||||
self._download_openers: set[threading.Thread] = set()
|
self._download_openers: set[threading.Thread] = set()
|
||||||
self._defer_download = defer_download
|
self._defer_download = defer_download
|
||||||
|
# Startup may defer the first model load to a background thread (#50):
|
||||||
|
# the engine reports model_loading until that load has finished.
|
||||||
|
self._model_loading = bool(kwargs.get("defer_model_load", False))
|
||||||
|
self._model_load_started = False
|
||||||
|
self._model_load_thread: Optional[threading.Thread] = None
|
||||||
|
self._model_load_callbacks: list[Callable[[], None]] = []
|
||||||
self._model_initialized = False
|
self._model_initialized = False
|
||||||
# True while a configured app/game blocks dictation, including pending unloads
|
# True while a configured app/game blocks dictation, including pending unloads
|
||||||
self._auto_paused = False
|
self._auto_paused = False
|
||||||
@@ -690,21 +699,14 @@ class SpeechRecognitionManager:
|
|||||||
f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model"
|
f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize the selected speech recognition engine
|
# Initialize the selected speech recognition engine. A deferred load is
|
||||||
if engine == "vosk":
|
# started later by load_model_in_background(); the engine name is still
|
||||||
self._init_vosk()
|
# checked now so a bad name fails at construction, as before.
|
||||||
elif engine == "whisper":
|
if self._model_loading:
|
||||||
self._init_whisper()
|
if engine not in _SUPPORTED_ENGINES:
|
||||||
elif engine == "whisper_cpp":
|
raise ValueError(f"Unsupported speech recognition engine: {engine}")
|
||||||
self._init_whispercpp()
|
|
||||||
elif engine == "parakeet":
|
|
||||||
self._init_parakeet()
|
|
||||||
elif engine == "faster_whisper":
|
|
||||||
self._init_faster_whisper()
|
|
||||||
elif engine == "remote_api":
|
|
||||||
self._init_remote_api()
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported speech recognition engine: {engine}")
|
self._init_selected_engine()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_language_candidates(value: str | list[str] | None) -> list[str]:
|
def _normalize_language_candidates(value: str | list[str] | None) -> list[str]:
|
||||||
@@ -3300,6 +3302,83 @@ class SpeechRecognitionManager:
|
|||||||
for callback in self.state_callbacks:
|
for callback in self.state_callbacks:
|
||||||
callback(new_state)
|
callback(new_state)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_loading(self) -> bool:
|
||||||
|
"""True from a deferred construction until its background load has finished (#50)."""
|
||||||
|
return bool(getattr(self, "_model_loading", False))
|
||||||
|
|
||||||
|
def register_model_load_callback(self, callback: Callable[[], None]) -> None:
|
||||||
|
"""Call ``callback()`` on the loader thread once a deferred load has finished.
|
||||||
|
|
||||||
|
Fires whether the load succeeded, failed or was skipped. Consumers marshal
|
||||||
|
onto the GTK main loop themselves (#50).
|
||||||
|
"""
|
||||||
|
self._model_load_callbacks.append(callback)
|
||||||
|
|
||||||
|
def load_model_in_background(self) -> None:
|
||||||
|
"""Start the deferred first model load on a daemon thread (#50).
|
||||||
|
|
||||||
|
A no-op unless the manager was built with ``defer_model_load=True``, and
|
||||||
|
only the first call starts a load. The request token is taken here, on
|
||||||
|
the caller's thread, so an auto-pause that lands before the loader gets
|
||||||
|
the model lock supersedes the load instead of racing it.
|
||||||
|
"""
|
||||||
|
if not self._model_loading or self._model_load_started:
|
||||||
|
return
|
||||||
|
self._model_load_started = True
|
||||||
|
request = self._new_model_request()
|
||||||
|
self._model_load_thread = threading.Thread(
|
||||||
|
target=self._run_background_model_load,
|
||||||
|
args=(request,),
|
||||||
|
name="voculinux-model-load",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._model_load_thread.start()
|
||||||
|
|
||||||
|
def _run_background_model_load(self, request: object) -> None:
|
||||||
|
"""Initialize the configured engine on the loader thread (#50).
|
||||||
|
|
||||||
|
Shares the model lock and request ownership with resume, but skips the
|
||||||
|
resume-only bookkeeping: a startup load publishes the model and nothing
|
||||||
|
else. The engine's own loading ladder and fallbacks run unchanged.
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
with self._model_lock:
|
||||||
|
# An auto-pause that is already active (a listed app is running)
|
||||||
|
# must keep the model out of memory; auto-resume loads it later.
|
||||||
|
if self._auto_paused or not self._is_current_model_request(request):
|
||||||
|
logger.info("Speech model load skipped: auto-paused or superseded")
|
||||||
|
else:
|
||||||
|
with self._model_request_context(request):
|
||||||
|
self._init_selected_engine()
|
||||||
|
logger.info("Speech model loaded in %.2fs", time.monotonic() - started)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Speech model failed to load", exc_info=True)
|
||||||
|
if self._is_current_model_request(request):
|
||||||
|
self._update_state(RecognitionState.ERROR, model_request=request)
|
||||||
|
_show_notification(
|
||||||
|
"Speech model failed to load",
|
||||||
|
f"{error}. Restart Voculinux to try again.",
|
||||||
|
"dialog-error",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._model_loading = False
|
||||||
|
self._notify_model_load_callbacks()
|
||||||
|
|
||||||
|
def _is_current_model_request(self, request: object) -> bool:
|
||||||
|
"""Whether no auto-pause or reconfigure has superseded ``request``."""
|
||||||
|
with getattr(self, "_model_request_lock", nullcontext()):
|
||||||
|
return self._model_request is request
|
||||||
|
|
||||||
|
def _notify_model_load_callbacks(self) -> None:
|
||||||
|
"""Run each load-state callback; one failing consumer must not stop the rest."""
|
||||||
|
for callback in list(self._model_load_callbacks):
|
||||||
|
try:
|
||||||
|
callback()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Model load callback failed", exc_info=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_ready(self) -> bool:
|
def model_ready(self) -> bool:
|
||||||
"""Check if the model is initialized and ready for recognition."""
|
"""Check if the model is initialized and ready for recognition."""
|
||||||
@@ -3501,6 +3580,17 @@ class SpeechRecognitionManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
reload_in_background = False
|
reload_in_background = False
|
||||||
|
# The first load (#50) is still running. Nothing can be dictated yet, and
|
||||||
|
# the missing-model offer below would be wrong, so say that instead.
|
||||||
|
if self.model_loading and not self.model_ready:
|
||||||
|
logger.info("Cannot start recognition: speech model is still loading")
|
||||||
|
play_error_sound()
|
||||||
|
_show_notification(
|
||||||
|
"Speech model loading",
|
||||||
|
"Dictation will be available once the speech model has finished loading.",
|
||||||
|
"dialog-information",
|
||||||
|
)
|
||||||
|
return False
|
||||||
# Check if model is ready (lazy-reload after idle keep-alive unload)
|
# Check if model is ready (lazy-reload after idle keep-alive unload)
|
||||||
if not self.model_ready:
|
if not self.model_ready:
|
||||||
if self._idle_unloaded:
|
if self._idle_unloaded:
|
||||||
@@ -3932,7 +4022,13 @@ class SpeechRecognitionManager:
|
|||||||
# select it.
|
# select it.
|
||||||
self._refuse_language_override(language)
|
self._refuse_language_override(language)
|
||||||
return False
|
return False
|
||||||
if target != self.language and not self._serving_model_supports_language_switch():
|
# While the first load runs there is no serving model to judge yet; the
|
||||||
|
# start itself is refused with the loading notice.
|
||||||
|
if (
|
||||||
|
target != self.language
|
||||||
|
and not self.model_loading
|
||||||
|
and not self._serving_model_supports_language_switch()
|
||||||
|
):
|
||||||
self._refuse_language_override(language)
|
self._refuse_language_override(language)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -9346,6 +9346,10 @@ class SettingsDialog(AboutSectionMixin):
|
|||||||
"Dictation is paused. Close the listed app or remove it from "
|
"Dictation is paused. Close the listed app or remove it from "
|
||||||
"Auto-Pause settings."
|
"Auto-Pause settings."
|
||||||
)
|
)
|
||||||
|
elif getattr(self.speech_engine, "model_loading", False) is True:
|
||||||
|
self.test_buffer.set_text(
|
||||||
|
"The speech model is still loading. Try Test Dictation again in a moment."
|
||||||
|
)
|
||||||
elif not getattr(self.speech_engine, "model_ready", True):
|
elif not getattr(self.speech_engine, "model_ready", True):
|
||||||
self.test_buffer.set_text(
|
self.test_buffer.set_text(
|
||||||
"No speech model downloaded. Open the Speech Model page and "
|
"No speech model downloaded. Open the Speech Model page and "
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ _INPUT_SETTLE_SECONDS = 2
|
|||||||
_INPUT_MONITOR_CAP_SECONDS = 10
|
_INPUT_MONITOR_CAP_SECONDS = 10
|
||||||
_FALLBACK_KEYBOARD_RESTART_SECONDS = 6
|
_FALLBACK_KEYBOARD_RESTART_SECONDS = 6
|
||||||
|
|
||||||
|
# Tray title while the speech model loads in the background (#50).
|
||||||
|
LOADING_TITLE = "Loading model…"
|
||||||
|
|
||||||
|
|
||||||
class TrayIndicator:
|
class TrayIndicator:
|
||||||
"""
|
"""
|
||||||
@@ -282,6 +285,11 @@ class TrayIndicator:
|
|||||||
|
|
||||||
# Register for speech recognition state changes
|
# Register for speech recognition state changes
|
||||||
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
|
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
|
||||||
|
# The model may still be loading in the background (#50); re-render when
|
||||||
|
# that load finishes.
|
||||||
|
register_load_callback = getattr(self.speech_engine, "register_model_load_callback", None)
|
||||||
|
if callable(register_load_callback):
|
||||||
|
register_load_callback(self._on_model_load_changed)
|
||||||
|
|
||||||
# Offer the download instead of only telling the user a model is missing
|
# Offer the download instead of only telling the user a model is missing
|
||||||
self._model_download_active = False
|
self._model_download_active = False
|
||||||
@@ -913,6 +921,25 @@ class TrayIndicator:
|
|||||||
if isinstance(indicator, GMenuIndicator):
|
if isinstance(indicator, GMenuIndicator):
|
||||||
indicator.refresh_menu()
|
indicator.refresh_menu()
|
||||||
|
|
||||||
|
def _engine_model_loading(self) -> bool:
|
||||||
|
"""True only while the engine reports its first model load in progress (#50)."""
|
||||||
|
return getattr(self.speech_engine, "model_loading", False) is True
|
||||||
|
|
||||||
|
def _on_model_load_changed(self) -> None:
|
||||||
|
"""Model load finished. Runs on the loader thread, so marshal it (#50)."""
|
||||||
|
GLib.idle_add(self._apply_model_load_state)
|
||||||
|
|
||||||
|
def _apply_model_load_state(self) -> bool:
|
||||||
|
"""Re-render after a model load changed state (GTK main loop only, #50)."""
|
||||||
|
if not self._engine_model_loading():
|
||||||
|
# Keep-alive refuses to unload while the load runs; restart its
|
||||||
|
# idle timer now that the model is in memory.
|
||||||
|
keepalive = getattr(self, "_model_keepalive", None)
|
||||||
|
if keepalive is not None:
|
||||||
|
keepalive.bump()
|
||||||
|
self._update_ui(getattr(self.speech_engine, "state", RecognitionState.IDLE))
|
||||||
|
return False # One-shot idle callback
|
||||||
|
|
||||||
def _on_recognition_state_changed(self, state: RecognitionState):
|
def _on_recognition_state_changed(self, state: RecognitionState):
|
||||||
"""
|
"""
|
||||||
Handle changes in the speech recognition state.
|
Handle changes in the speech recognition state.
|
||||||
@@ -1323,7 +1350,12 @@ class TrayIndicator:
|
|||||||
show_cancel = state == RecognitionState.PROCESSING and transcribing
|
show_cancel = state == RecognitionState.PROCESSING and transcribing
|
||||||
self._set_cancel_menu_item_visible(show_cancel)
|
self._set_cancel_menu_item_visible(show_cancel)
|
||||||
|
|
||||||
if state == RecognitionState.IDLE:
|
if state == RecognitionState.IDLE and self._engine_model_loading():
|
||||||
|
self._set_indicator_title(LOADING_TITLE)
|
||||||
|
self._set_indicator_icon(self._icon_keys["processing"], "Loading speech model")
|
||||||
|
self._set_menu_item_enabled("Start Voice Typing", False)
|
||||||
|
self._set_menu_item_enabled("Stop Voice Typing", False)
|
||||||
|
elif state == RecognitionState.IDLE:
|
||||||
self._set_indicator_title(TRAY_TITLE)
|
self._set_indicator_title(TRAY_TITLE)
|
||||||
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
|
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
|
||||||
self._set_menu_item_enabled("Start Voice Typing", True)
|
self._set_menu_item_enabled("Start Voice Typing", True)
|
||||||
@@ -1714,6 +1746,9 @@ class TrayIndicator:
|
|||||||
|
|
||||||
def _is_safe_for_keepalive_unload(self) -> bool:
|
def _is_safe_for_keepalive_unload(self) -> bool:
|
||||||
"""Keep-alive may unload only when idle and not under auto-pause."""
|
"""Keep-alive may unload only when idle and not under auto-pause."""
|
||||||
|
if self._engine_model_loading():
|
||||||
|
# The first load (#50) is still putting the model in memory.
|
||||||
|
return False
|
||||||
if getattr(self.speech_engine, "is_auto_paused", False) is True:
|
if getattr(self.speech_engine, "is_auto_paused", False) is True:
|
||||||
return False
|
return False
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ class TestMainModule(unittest.TestCase):
|
|||||||
remote_api_key="",
|
remote_api_key="",
|
||||||
remote_api_endpoint="/inference",
|
remote_api_endpoint="/inference",
|
||||||
remote_api_model="whisper-1",
|
remote_api_model="whisper-1",
|
||||||
|
defer_model_load=True,
|
||||||
)
|
)
|
||||||
mock_text.assert_called_once_with(wayland_mode=True)
|
mock_text.assert_called_once_with(wayland_mode=True)
|
||||||
mock_action_handler.assert_called_once_with(mock_text_instance)
|
mock_action_handler.assert_called_once_with(mock_text_instance)
|
||||||
@@ -286,6 +287,69 @@ class TestMainModule(unittest.TestCase):
|
|||||||
# Verify the tray indicator was started
|
# Verify the tray indicator was started
|
||||||
mock_tray_instance.run.assert_called_once()
|
mock_tray_instance.run.assert_called_once()
|
||||||
|
|
||||||
|
@patch("vocalinux.main.check_dependencies")
|
||||||
|
@patch("vocalinux.ui.dictation_pad.DictationPad")
|
||||||
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
||||||
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
||||||
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
||||||
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
||||||
|
@patch("vocalinux.main.logging")
|
||||||
|
@patch("vocalinux.ui.config_manager.get_shared_config_manager")
|
||||||
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
||||||
|
def test_tray_is_shown_before_the_model_load_is_scheduled(
|
||||||
|
self,
|
||||||
|
mock_init_logging,
|
||||||
|
mock_config_manager,
|
||||||
|
mock_logging,
|
||||||
|
mock_tray,
|
||||||
|
mock_text,
|
||||||
|
mock_speech,
|
||||||
|
mock_action_handler,
|
||||||
|
mock_dictation_pad,
|
||||||
|
mock_check_deps,
|
||||||
|
):
|
||||||
|
"""The tray is built first, then the model load is queued, then the loop runs (#50)."""
|
||||||
|
mock_check_deps.return_value = True
|
||||||
|
mock_config_instance = MagicMock()
|
||||||
|
mock_config_instance.get_bool.return_value = True
|
||||||
|
mock_config_instance.get_settings.return_value = {
|
||||||
|
"speech_recognition": {},
|
||||||
|
"general": {"first_run": False},
|
||||||
|
}
|
||||||
|
mock_config_instance.get_model_size_for_engine.return_value = "medium"
|
||||||
|
mock_config_instance.get_str.return_value = ""
|
||||||
|
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||||
|
mock_config_manager.return_value = mock_config_instance
|
||||||
|
|
||||||
|
mock_speech_instance = MagicMock()
|
||||||
|
mock_speech.return_value = mock_speech_instance
|
||||||
|
mock_tray_instance = MagicMock()
|
||||||
|
mock_tray.return_value = mock_tray_instance
|
||||||
|
|
||||||
|
order = MagicMock()
|
||||||
|
order.attach_mock(mock_tray, "tray")
|
||||||
|
order.attach_mock(mock_tray_instance.run, "run")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("vocalinux.main.parse_arguments") as mock_parse,
|
||||||
|
patch("gi.repository.GLib.idle_add") as mock_idle_add,
|
||||||
|
):
|
||||||
|
order.attach_mock(mock_idle_add, "idle_add")
|
||||||
|
mock_args = MagicMock()
|
||||||
|
mock_args.debug = False
|
||||||
|
mock_args.model = "medium"
|
||||||
|
mock_args.engine = "whisper_cpp"
|
||||||
|
mock_args.language = "en-us"
|
||||||
|
mock_args.wayland = False
|
||||||
|
mock_parse.return_value = mock_args
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
mock_speech.assert_called_once()
|
||||||
|
self.assertIs(mock_speech.call_args.kwargs["defer_model_load"], True)
|
||||||
|
mock_idle_add.assert_called_once_with(mock_speech_instance.load_model_in_background)
|
||||||
|
self.assertEqual([c[0] for c in order.mock_calls], ["tray", "idle_add", "run"])
|
||||||
|
|
||||||
@patch("vocalinux.main.check_dependencies")
|
@patch("vocalinux.main.check_dependencies")
|
||||||
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
||||||
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Startup model load runs on a background thread so the tray can show first (#50).
|
||||||
|
|
||||||
|
Seams covered here, all at the SpeechRecognitionManager public surface:
|
||||||
|
the deferred-load constructor flag, ``load_model_in_background`` (thread,
|
||||||
|
loading flag, callbacks, failure reporting), the dictation guard while the
|
||||||
|
model is loading, supersession by an auto-pause, and the language-shortcut
|
||||||
|
path while loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
MANAGER_MODULE = "vocalinux.speech_recognition.recognition_manager"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def init_whispercpp():
|
||||||
|
"""Stand in for the whisper.cpp load so no model file is read."""
|
||||||
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||||
|
|
||||||
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp") as init:
|
||||||
|
yield init
|
||||||
|
|
||||||
|
|
||||||
|
def _deferred_manager():
|
||||||
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||||
|
|
||||||
|
return SpeechRecognitionManager(
|
||||||
|
engine="whisper_cpp",
|
||||||
|
model_size="small",
|
||||||
|
language="en-us",
|
||||||
|
defer_model_load=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeferredConstruction:
|
||||||
|
def test_construction_does_not_load_the_engine(self, init_whispercpp):
|
||||||
|
manager = _deferred_manager()
|
||||||
|
|
||||||
|
init_whispercpp.assert_not_called()
|
||||||
|
assert manager.model_loading is True
|
||||||
|
assert manager.model_ready is False
|
||||||
|
|
||||||
|
def test_default_construction_still_loads_synchronously(self, init_whispercpp):
|
||||||
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||||
|
|
||||||
|
manager = SpeechRecognitionManager(
|
||||||
|
engine="whisper_cpp", model_size="small", language="en-us"
|
||||||
|
)
|
||||||
|
|
||||||
|
init_whispercpp.assert_called_once_with()
|
||||||
|
assert manager.model_loading is False
|
||||||
|
|
||||||
|
def test_unknown_engine_still_fails_at_construction(self, init_whispercpp):
|
||||||
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unsupported speech recognition engine"):
|
||||||
|
SpeechRecognitionManager(
|
||||||
|
engine="no-such-engine",
|
||||||
|
model_size="small",
|
||||||
|
language="en-us",
|
||||||
|
defer_model_load=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundLoad:
|
||||||
|
def test_load_runs_on_a_daemon_thread_and_returns_at_once(self, init_whispercpp):
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
seen_threads = []
|
||||||
|
|
||||||
|
def slow_load(*_args, **_kwargs):
|
||||||
|
seen_threads.append(threading.current_thread())
|
||||||
|
entered.set()
|
||||||
|
release.wait(5)
|
||||||
|
|
||||||
|
init_whispercpp.side_effect = slow_load
|
||||||
|
manager = _deferred_manager()
|
||||||
|
|
||||||
|
manager.load_model_in_background()
|
||||||
|
|
||||||
|
assert entered.wait(5), "the load never started"
|
||||||
|
assert manager.model_loading is True
|
||||||
|
assert seen_threads[0] is not threading.main_thread()
|
||||||
|
assert seen_threads[0].daemon is True
|
||||||
|
release.set()
|
||||||
|
manager._model_load_thread.join(5)
|
||||||
|
|
||||||
|
def test_loading_flag_clears_and_callbacks_fire_after_success(self, init_whispercpp):
|
||||||
|
manager = _deferred_manager()
|
||||||
|
flags_seen_by_callback = []
|
||||||
|
manager.register_model_load_callback(
|
||||||
|
lambda: flags_seen_by_callback.append(manager.model_loading)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.load_model_in_background()
|
||||||
|
manager._model_load_thread.join(5)
|
||||||
|
|
||||||
|
init_whispercpp.assert_called_once_with()
|
||||||
|
assert manager.model_loading is False
|
||||||
|
assert flags_seen_by_callback, "the load-finished callback never ran"
|
||||||
|
assert flags_seen_by_callback[-1] is False
|
||||||
|
|
||||||
|
def test_failed_load_reports_error_instead_of_raising(self, init_whispercpp):
|
||||||
|
from vocalinux.common_types import RecognitionState
|
||||||
|
|
||||||
|
init_whispercpp.side_effect = RuntimeError("model file is corrupt")
|
||||||
|
manager = _deferred_manager()
|
||||||
|
states = []
|
||||||
|
manager.register_state_callback(states.append)
|
||||||
|
|
||||||
|
with patch(f"{MANAGER_MODULE}._show_notification") as notify:
|
||||||
|
manager.load_model_in_background()
|
||||||
|
manager._model_load_thread.join(5)
|
||||||
|
|
||||||
|
assert manager.model_loading is False
|
||||||
|
assert manager.state == RecognitionState.ERROR
|
||||||
|
assert states[-1] == RecognitionState.ERROR
|
||||||
|
notify.assert_called_once()
|
||||||
|
assert "failed to load" in notify.call_args.args[0].lower()
|
||||||
|
|
||||||
|
def test_auto_pause_already_active_at_startup_skips_the_load(self, init_whispercpp):
|
||||||
|
manager = _deferred_manager()
|
||||||
|
# A configured app is already running when the tray comes up. Its
|
||||||
|
# auto-pause runs before the load is scheduled, so the load must not
|
||||||
|
# keep the model resident; auto-resume loads it when the app exits.
|
||||||
|
manager._new_model_request(auto_pause=True)
|
||||||
|
|
||||||
|
manager.load_model_in_background()
|
||||||
|
manager._model_load_thread.join(5)
|
||||||
|
|
||||||
|
init_whispercpp.assert_not_called()
|
||||||
|
assert manager.is_auto_paused is True
|
||||||
|
assert manager.model_loading is False
|
||||||
|
|
||||||
|
def test_auto_pause_arriving_before_the_load_supersedes_it(self, init_whispercpp):
|
||||||
|
manager = _deferred_manager()
|
||||||
|
|
||||||
|
# The loader must wait for the model lock; an auto-pause takes a newer
|
||||||
|
# request while the lock is still held, so the load must not run.
|
||||||
|
with manager._model_lock:
|
||||||
|
manager.load_model_in_background()
|
||||||
|
manager._new_model_request(auto_pause=True)
|
||||||
|
manager._model_load_thread.join(5)
|
||||||
|
|
||||||
|
init_whispercpp.assert_not_called()
|
||||||
|
assert manager.is_auto_paused is True
|
||||||
|
assert manager.model_loading is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDictationWhileLoading:
|
||||||
|
def test_start_is_refused_with_a_loading_notice(self, init_whispercpp):
|
||||||
|
from vocalinux.common_types import RecognitionState
|
||||||
|
|
||||||
|
manager = _deferred_manager()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MANAGER_MODULE}._show_notification") as notify,
|
||||||
|
patch(f"{MANAGER_MODULE}.play_error_sound") as error_sound,
|
||||||
|
patch.object(manager, "_notify_model_missing") as model_missing_offer,
|
||||||
|
):
|
||||||
|
started = manager.start_recognition()
|
||||||
|
|
||||||
|
assert started is False
|
||||||
|
assert manager.state == RecognitionState.IDLE
|
||||||
|
model_missing_offer.assert_not_called()
|
||||||
|
error_sound.assert_called_once()
|
||||||
|
assert "loading" in notify.call_args.args[0].lower()
|
||||||
|
init_whispercpp.assert_not_called()
|
||||||
|
|
||||||
|
def test_language_shortcut_during_load_reports_loading_not_unsupported(self, init_whispercpp):
|
||||||
|
manager = _deferred_manager()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(manager, "_refuse_language_override") as refuse,
|
||||||
|
patch(f"{MANAGER_MODULE}._show_notification") as notify,
|
||||||
|
patch(f"{MANAGER_MODULE}.play_error_sound"),
|
||||||
|
):
|
||||||
|
started = manager.start_recognition_with_language("de")
|
||||||
|
|
||||||
|
assert started is False
|
||||||
|
refuse.assert_not_called()
|
||||||
|
assert "loading" in notify.call_args.args[0].lower()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tray rendering while the speech model loads in the background (#50).
|
||||||
|
|
||||||
|
The tray is built before the model load starts, so it must show a loading
|
||||||
|
state, re-render on the GTK main loop when the load finishes, and keep the
|
||||||
|
idle keep-alive from unloading a model that is still being loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from vocalinux.common_types import RecognitionState
|
||||||
|
from vocalinux.ui import tray_indicator
|
||||||
|
|
||||||
|
LOADING_TITLE = "Loading model…"
|
||||||
|
ICONS = {
|
||||||
|
"default": "default-icon",
|
||||||
|
"active": "active-icon",
|
||||||
|
"processing": "processing-icon",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(state=RecognitionState.IDLE, loading=False):
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.state = state
|
||||||
|
engine.model_loading = loading
|
||||||
|
engine.is_auto_paused = False
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tray(engine):
|
||||||
|
"""Build a TrayIndicator without running GTK setup; only rendering helpers are real."""
|
||||||
|
tray = tray_indicator.TrayIndicator.__new__(tray_indicator.TrayIndicator)
|
||||||
|
tray.speech_engine = engine
|
||||||
|
tray.indicator = MagicMock()
|
||||||
|
tray._icon_keys = dict(ICONS)
|
||||||
|
tray._indicator_title = tray_indicator.TRAY_TITLE
|
||||||
|
tray._language_shortcuts_refresh_pending = False
|
||||||
|
tray.overlay = None
|
||||||
|
tray.shortcut_manager = MagicMock()
|
||||||
|
tray._model_keepalive = MagicMock()
|
||||||
|
tray._auto_pause_monitor = MagicMock(paused=False)
|
||||||
|
tray._set_indicator_icon = MagicMock()
|
||||||
|
tray._set_indicator_title = MagicMock()
|
||||||
|
tray._set_menu_item_enabled = MagicMock()
|
||||||
|
tray._set_cancel_menu_item_visible = MagicMock()
|
||||||
|
return tray
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_shows_processing_icon_and_loading_title_while_idle():
|
||||||
|
tray = _make_tray(_engine(loading=True))
|
||||||
|
|
||||||
|
tray._update_ui(RecognitionState.IDLE)
|
||||||
|
|
||||||
|
tray._set_indicator_icon.assert_called_with(ICONS["processing"], "Loading speech model")
|
||||||
|
tray._set_indicator_title.assert_called_with(LOADING_TITLE)
|
||||||
|
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", False)
|
||||||
|
tray._set_menu_item_enabled.assert_any_call("Stop Voice Typing", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finished_load_returns_to_idle_icon_and_enables_start():
|
||||||
|
tray = _make_tray(_engine(loading=False))
|
||||||
|
|
||||||
|
tray._update_ui(RecognitionState.IDLE)
|
||||||
|
|
||||||
|
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
|
||||||
|
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_state_does_not_mask_an_active_session():
|
||||||
|
tray = _make_tray(_engine(state=RecognitionState.LISTENING, loading=True))
|
||||||
|
|
||||||
|
tray._update_ui(RecognitionState.LISTENING)
|
||||||
|
|
||||||
|
tray._set_indicator_icon.assert_called_with(ICONS["active"], "Microphone on")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_change_is_marshalled_onto_the_gtk_main_loop():
|
||||||
|
tray = _make_tray(_engine(loading=False))
|
||||||
|
|
||||||
|
with patch.object(tray_indicator, "GLib") as glib:
|
||||||
|
tray._on_model_load_changed()
|
||||||
|
|
||||||
|
glib.idle_add.assert_called_once_with(tray._apply_model_load_state)
|
||||||
|
tray._set_indicator_icon.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finishing_the_load_restarts_the_keep_alive_timer():
|
||||||
|
engine = _engine(loading=False)
|
||||||
|
tray = _make_tray(engine)
|
||||||
|
|
||||||
|
assert tray._apply_model_load_state() is False
|
||||||
|
|
||||||
|
tray._model_keepalive.bump.assert_called_once_with()
|
||||||
|
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_in_progress_does_not_restart_the_keep_alive_timer():
|
||||||
|
tray = _make_tray(_engine(loading=True))
|
||||||
|
|
||||||
|
tray._apply_model_load_state()
|
||||||
|
|
||||||
|
tray._model_keepalive.bump.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_alive_never_unloads_while_the_model_is_loading():
|
||||||
|
loading = _make_tray(_engine(loading=True))
|
||||||
|
loaded = _make_tray(_engine(loading=False))
|
||||||
|
|
||||||
|
assert loading._is_safe_for_keepalive_unload() is False
|
||||||
|
assert loaded._is_safe_for_keepalive_unload() is True
|
||||||
Reference in New Issue
Block a user