diff --git a/cli.py b/cli.py index 4635b9c8adf0..04528f20933c 100644 --- a/cli.py +++ b/cli.py @@ -12222,6 +12222,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f" {_DIM}{reqs['hint']}{_RST}") return False + if announce and not reqs.get("deps_available", True): + # Fresh install: the engine constructor lazy-installs its deps + # (onnxruntime is a large wheel) — tell the user why this is slow. + _cprint(f"{_DIM}Installing wake word engine (first use — this may take a minute)...{_RST}") + self._wake_start_new_session = bool(cfg.get("start_new_session", True)) try: start_listening(self._on_wake_word, owner=self, config=cfg) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index afe74ea6f4bb..45e87b36eec7 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -112,6 +112,53 @@ def test_requirements_unavailable_without_audio(monkeypatch): assert r["audio_available"] is False +def test_requirements_fresh_install_lazy_allowed(monkeypatch): + """Deps missing + lazy installs allowed → available, so /wake on can + reach the engine constructor's ``lazy_deps.ensure()`` call. + + Regression: the audio probe imports sounddevice/numpy — packages the + lazy installer would fetch — so gating ``available`` on it made the + lazy-install path unreachable on a fresh machine (the /wake on handler + printed the pip hint and bailed before ensure() ever ran). + """ + def _boom(): + raise AssertionError("audio probe must not run while deps are missing") + + monkeypatch.setattr(ww, "_audio_available", _boom) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is True + assert r["deps_available"] is False + assert r["hint"] == "" + + +def test_requirements_fresh_install_lazy_disabled(monkeypatch): + """Deps missing + lazy installs disabled → unavailable, with the manual + pip command as the remediation hint.""" + monkeypatch.setattr(ww, "_audio_available", lambda: True) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + monkeypatch.setattr( + "tools.lazy_deps.feature_install_command", lambda f: f"uv pip install {f}" + ) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert r["deps_available"] is False + assert "install" in r["hint"] + + +def test_requirements_deps_present_but_no_audio_hint(monkeypatch): + """Once deps ARE installed, a failing audio probe blocks with a mic hint + (lazy installs can't fix a missing audio device).""" + monkeypatch.setattr(ww, "_audio_available", lambda: False) + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + r = ww.check_wake_word_requirements({"provider": "openwakeword"}) + assert r["available"] is False + assert "audio device" in r["hint"] + + # ── openWakeWord engine (bundled model + base-model fetch) ─────────────── diff --git a/tools/wake_word.py b/tools/wake_word.py index a1bb8e43bd80..e1211a61811c 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -507,20 +507,28 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s else: feature = "wake.openwakeword" deps_ok = lazy_deps.is_available(feature) - audio_ok = _audio_available() + lazy_ok = lazy_deps._allow_lazy_installs() + # The audio probe imports sounddevice + numpy — two of the very packages + # the lazy installer would fetch — so it can only be trusted once the + # feature's deps are installed. On a fresh install (deps missing, lazy + # installs allowed) we defer the mic check: the engine constructors call + # ``lazy_deps.ensure()`` and the stream-open surfaces any real audio + # problem. Gating ``available`` on the probe here made the lazy-install + # path unreachable (the probe always failed before ensure() could run). + audio_ok = _audio_available() if deps_ok else False key_ok = True hint = "" if provider == "porcupine" and not (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip(): key_ok = False hint = "Set PORCUPINE_ACCESS_KEY (free key at https://console.picovoice.ai)." - elif not deps_ok: + elif not deps_ok and not lazy_ok: hint = lazy_deps.feature_install_command(feature) or "" - elif not audio_ok: + elif deps_ok and not audio_ok: hint = "Microphone capture needs sounddevice + numpy and a working audio device." return { - "available": audio_ok and (deps_ok or lazy_deps._allow_lazy_installs()) and key_ok, + "available": key_ok and ((deps_ok and audio_ok) or (not deps_ok and lazy_ok)), "provider": provider, "deps_available": deps_ok, "audio_available": audio_ok,