From d1f5761f7591495794734e79c98f7199100153c0 Mon Sep 17 00:00:00 2001 From: ryanfelder Date: Mon, 17 Aug 2026 18:07:33 -0500 Subject: [PATCH] Restore stop word sensitivity from preferences on startup (#394) * Restore stop word sensitivity from preferences on startup The Home Assistant number entity persists preferences.stop_word_sensitivity whenever it changes, but nothing ever read it back. main() did not pass stop_word_threshold when constructing ServerState, so it always took the dataclass default of 0.5, and lowering the sensitivity in Home Assistant silently reverted at the next restart. volume and mic_volume are restored at startup, and the two wake word sensitivities are restored when wake models load. Only the stop word was missed, so the block that syncs all three sensitivity entities back to Home Assistant was pushing a default it had never loaded. Restored alongside volume and clamped the same way. The resolution lives in a small helper so it can be covered by tests. * Move the threshold helper to models.py so tests do not import __main__ CI has no PulseAudio daemon. Importing linux_voice_assistant.__main__ pulls in `import soundcard as sc`, and soundcard/pulseaudio.py instantiates _PulseAudio() at module scope, which asserts on a live connection. So the four new tests failed on the runner while passing locally, where a daemon happens to be running. That is also why no other test in the suite imports __main__, and why the existing tests in test_main.py re-implement the logic they check rather than calling it. initial_stop_word_threshold() now lives in models.py, which owns both fields it resolves between: Preferences.stop_word_sensitivity and ServerState.stop_word_threshold. It imports cleanly, test_models.py already imports it, and the reference to the ServerState default becomes local. Behaviour is unchanged. Verified by reproducing the CI environment locally with PULSE_SERVER pointed at a nonexistent socket: 4 failed before, 298 pass after. --------- Co-authored-by: ryan felder <7475484+ryanfelder@users.noreply.github.com> Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com> --- linux_voice_assistant/__main__.py | 7 ++++++- linux_voice_assistant/models.py | 11 +++++++++++ tests/unit/test_models.py | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 38526d4..1a9b069 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -18,7 +18,7 @@ from getmac import get_mac_address # type: ignore from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures -from .models import Preferences, ServerState, WakeWordType +from .models import Preferences, ServerState, WakeWordType, initial_stop_word_threshold from .mpv_player import MpvMediaPlayer from .peripheral_api import LVAEvent, PeripheralAPIServer from .satellite import VoiceSatelliteProtocol @@ -379,6 +379,10 @@ async def main() -> None: initial_volume = max(0.0, min(1.0, float(initial_volume))) preferences.volume = initial_volume + # Load stop word sensitivity from preferences on startup, and ensure it's between 0.0 and 1.0 + initial_threshold = initial_stop_word_threshold(preferences.stop_word_sensitivity) + preferences.stop_word_sensitivity = initial_threshold + if args.enable_thinking_sound: preferences.thinking_sound = 1 @@ -441,6 +445,7 @@ async def main() -> None: output_only=args.output_only, download_dir=args.download_dir, volume=initial_volume, + stop_word_threshold=initial_threshold, mic_volume=preferences.mic_volume, mic_auto_gain=preferences.mic_auto_gain, mic_noise_suppression=preferences.mic_noise_suppression, diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 85b1673..7586158 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -265,3 +265,14 @@ class ServerState: self.preferences.mic_volume = volume_int _LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path) self.save_preferences() + + +def initial_stop_word_threshold(saved_sensitivity: Optional[float]) -> float: + """ + Resolve the stop word probability cutoff to start from, clamped to 0.0-1.0. + :param saved_sensitivity: Value persisted in preferences, or None if it has never been set. + """ + if saved_sensitivity is None: + return ServerState.stop_word_threshold + + return max(0.0, min(1.0, float(saved_sensitivity))) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ef10016..974250c 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -282,3 +282,30 @@ class TestPersistMicNoise: with patch.object(state, "save_preferences") as mock_save: state.persist_mic_noise(4.0) mock_save.assert_called_once() + + +# --------------------------------------------------------------------------- +# initial_stop_word_threshold() +# --------------------------------------------------------------------------- + + +class TestInitialStopWordThreshold: + def test_saved_sensitivity_is_restored(self): + from linux_voice_assistant.models import initial_stop_word_threshold + + assert initial_stop_word_threshold(0.9) == pytest.approx(0.9) + + def test_falls_back_to_server_state_default_when_never_set(self): + from linux_voice_assistant.models import ServerState, initial_stop_word_threshold + + assert initial_stop_word_threshold(None) == pytest.approx(ServerState.stop_word_threshold) + + def test_clamped_to_one_when_above(self): + from linux_voice_assistant.models import initial_stop_word_threshold + + assert initial_stop_word_threshold(1.5) == pytest.approx(1.0) + + def test_clamped_to_zero_when_below(self): + from linux_voice_assistant.models import initial_stop_word_threshold + + assert initial_stop_word_threshold(-0.5) == pytest.approx(0.0)