diff --git a/pyproject.toml b/pyproject.toml index ac2318ea6dc..f84dbfcae45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,8 +178,9 @@ voice = [ # "Hey Hermes" wake word — on-device hotword detection. All engines are # optional; openWakeWord (ONNX) is the free default, sherpa-onnx adds # open-vocabulary phrases (any typed phrase, zero training), Porcupine is -# the premium alternative. Lazy-installed on first /wake; mirrored in -# tools/lazy_deps.py. +# the premium alternative. Desktop installs ([--include-desktop]) eager-install +# [wake]+[voice] so the ear works instantly; CLI-only installs lazy-install on +# first /wake; mirrored in tools/lazy_deps.py. wake = [ "openwakeword==0.6.0", "onnxruntime==1.27.0", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e02acfff881..a0bd451800e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2813,6 +2813,34 @@ function Try-RestoreElectronDist { return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror } +function Install-DesktopVoiceDeps { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Best-effort — lazy install remains the fallback for anything + # this step fails to fetch. + if (-not $script:UvCmd) { Resolve-UvCmd } + if (-not $script:UvCmd) { + Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead" + return + } + $env:VIRTUAL_ENV = "$InstallDir\venv" + Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..." + Push-Location $InstallDir + try { + Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" } + if ($LASTEXITCODE -eq 0) { + Write-Success "Voice + wake-word dependencies installed" + } else { + Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use" + } + } finally { + Pop-Location + } +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -3577,7 +3605,7 @@ function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } function Stage-NodeDeps { Install-NodeDeps } -function Stage-Desktop { Install-Desktop } +function Stage-Desktop { Install-DesktopVoiceDeps; Install-Desktop } function Stage-Path { Set-PathVariable } function Stage-ConfigTemplates { Copy-ConfigTemplates } function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } diff --git a/scripts/install.sh b/scripts/install.sh index df8739d62cd..ae3902d401e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2792,6 +2792,37 @@ _restore_electron_dist_with_fallback() { # (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked # via the 'desktop' stage / --include-desktop, which the Electron app's own # first-launch bootstrap never requests (it must not rebuild itself). +install_desktop_voice_deps() { + # Desktop ships with working voice out of the box: eagerly install the + # wake-word + local-STT stacks ([wake] + [voice] extras) instead of + # leaving them to lazy first-use install. Policy change (Teknium, July + # 2026, #70509 testing): the first ear-click used to trigger a + # multi-minute onnxruntime pip install that froze the UI and blew RPC + # timeouts. Lazy install remains the fallback for CLI-only installs and + # for anything this best-effort step fails to fetch. + local _prev_venv="${VIRTUAL_ENV:-}" + if [ "$USE_VENV" = true ]; then + export VIRTUAL_ENV="$INSTALL_DIR/venv" + fi + if [ -z "${UV_CMD:-}" ]; then + install_uv || true + fi + if [ -z "${UV_CMD:-}" ]; then + log_warn "uv unavailable — voice/wake deps will lazy-install at first use instead" + return 0 + fi + log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper — 1-3min)..." + if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then + log_success "Voice + wake-word dependencies installed" + else + log_warn "Voice/wake dependency install failed — they will lazy-install at first use" + fi + if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then + unset VIRTUAL_ENV + fi + return 0 +} + install_desktop() { local desktop_dir="$INSTALL_DIR/apps/desktop" @@ -3074,6 +3105,7 @@ run_stage_body() { # isn't on PATH here. check_node re-adds it (or installs if missing) # so install_desktop can find npm instead of silently skipping. check_node + install_desktop_voice_deps install_desktop ;; complete) @@ -3160,6 +3192,7 @@ main() { maybe_start_gateway if [ "$INCLUDE_DESKTOP" = true ]; then + install_desktop_voice_deps install_desktop fi diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index 2eb99edbccb..01495c02ebd 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -126,6 +126,43 @@ def test_requirements_need_stt_and_tts(monkeypatch): assert "speech-to-text and text-to-speech" in r["hint"] +def test_tts_ready_is_a_probe_never_an_installer(monkeypatch): + """_tts_ready must NOT trigger lazy pip installs from a status poll. + + Regression: check_tts_requirements → _import_edge_tts → lazy_deps.ensure + ran pip inside wake.status; a slow/failed install froze the poll and + unmounted the desktop ear. Uninstalled-but-lazy-installable counts as + ready WITHOUT calling ensure/check. + """ + import types as _types + + monkeypatch.setattr( + ww, "_tts_ready", ww.__dict__["_tts_ready"] + ) # use the real implementation + fake_tts = _types.SimpleNamespace( + _get_provider=lambda cfg: "edge", + _load_tts_config=lambda: {}, + check_tts_requirements=lambda: (_ for _ in ()).throw( + AssertionError("check_tts_requirements must not run when deps are missing") + ), + ) + monkeypatch.setitem(sys.modules, "tools.tts_tool", fake_tts) + + # Deps missing + lazy installs allowed → ready (installs at first speak). + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: False) + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: True) + assert ww._tts_ready() is True + + # Deps missing + lazy installs disabled → not ready. + monkeypatch.setattr("tools.lazy_deps._allow_lazy_installs", lambda: False) + assert ww._tts_ready() is False + + # Deps present → falls through to the real requirements check. + fake_tts.check_tts_requirements = lambda: True + monkeypatch.setattr("tools.lazy_deps.is_available", lambda f: True) + assert ww._tts_ready() is True + + def test_requirements_porcupine_needs_access_key(monkeypatch): monkeypatch.delenv("PORCUPINE_ACCESS_KEY", raising=False) monkeypatch.setattr(ww, "_audio_available", lambda: True) diff --git a/tools/wake_word.py b/tools/wake_word.py index b7cf50425ae..230bb7d9b13 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -519,12 +519,41 @@ def _stt_ready() -> bool: def _tts_ready() -> bool: - """Can the configured text-to-speech provider actually run? + """Can the configured text-to-speech provider run (or install at first use)? The wake flow is fully hands-free (wake → speak → hear the reply); without - TTS the reply is silent and the loop is pointless. Mirrors /voice's use of - ``check_tts_requirements``. + TTS the reply is silent and the loop is pointless. + + PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the + provider SDK via ``_import_*`` → ``lazy_deps.ensure`` — running that inside + a status poll froze wake.status for the length of a pip install (and a + failed install marked the wake word unavailable, unmounting the desktop + ear). When the provider's deps aren't installed yet, "installable at first + use" counts as ready and we never touch pip from here. """ + try: + from tools.tts_tool import _get_provider, _load_tts_config + + provider = _get_provider(_load_tts_config()) + except Exception: + return False + + _LAZY_TTS_FEATURES = { + "edge": "tts.edge", + "elevenlabs": "tts.elevenlabs", + "mistral": "tts.mistral", + } + feature = _LAZY_TTS_FEATURES.get(provider) + if feature is not None: + try: + from tools import lazy_deps + + if not lazy_deps.is_available(feature): + # Not installed: ready iff it can install at first speak. + return lazy_deps._allow_lazy_installs() + except Exception: + return False + try: from tools.tts_tool import check_tts_requirements diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index 577a68b5b6b..6e8735d16ab 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -40,8 +40,9 @@ By default the phrase is **"hey hermes"** — a model for it ships with Hermes, it works out of the box with no training. (On first use, openWakeWord downloads its shared feature-extraction models — a small one-time fetch.) -Both are lazy-installed the first time you enable the wake word. To install ahead -of time: +Both are lazy-installed the first time you enable the wake word (desktop +installs made with `--include-desktop` pre-install them, so the ear works +instantly). To install ahead of time: ```bash cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]"