feat(wake): eager-install voice deps with the desktop; wake probes never run pip

Two fixes from live testing (Teknium):

1. Desktop installs now ship the wake/voice stacks up front.
   install.sh + install.ps1 desktop stages run 'uv pip install
   -e .[wake,voice]' (best-effort, lazy-install remains the fallback)
   before building the app, so the first ear-click arms instantly
   instead of sitting through a multi-minute onnxruntime download.
   CLI-only installs keep the lazy path — [all] curation unchanged.

2. The vanished ear: the STT/TTS gate made wake.status call
   check_tts_requirements(), whose edge path runs _import_edge_tts →
   lazy_deps.ensure — a synchronous PIP INSTALL inside a status poll.
   On a venv without edge-tts that blew the desktop's 30s RPC timeout,
   armWakeWord caught the error, the atom never learned enabled=true,
   and the ear unmounted. _tts_ready is now a pure probe: deps missing
   + lazy installs allowed counts as ready (installs at first speak)
   WITHOUT touching pip; check_tts_requirements only runs once deps
   are present. Regression test asserts the probe never calls it while
   deps are missing.
This commit is contained in:
Teknium 2026-07-27 19:30:39 -07:00
parent 46faa4f639
commit a832139ba3
No known key found for this signature in database
6 changed files with 137 additions and 8 deletions

View file

@ -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",

View file

@ -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 }

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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]"