The model load ran inside SpeechRecognitionManager's constructor, so the tray was not built until the model was in memory and the app looked dead for the whole load. The manager can now defer its first load (defer_model_load). main() builds the tray first, then starts the load on a background thread from the GTK main loop. While the model loads the tray shows the processing icon, a "Loading model…" title, and disables Start/Stop Voice Typing. Dictation attempted during the load is refused with an error sound and a "Speech model loading" notification; it is not queued. A failed load leaves the tray in the Error state and shows a notification instead of exiting. Keep-alive does not unload while the first load runs. If an auto-pause is already active when the load would start, the load is skipped and auto-resume loads the model later. The model ladder, legacy import, D-Bus service and single-instance lock are unchanged. Fixes #50
186 lines
6.8 KiB
Python
186 lines
6.8 KiB
Python
"""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()
|