mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(wake): coerce dead onnx->tflite on macOS ARM64; clear stale voice turn-timeout
Two follow-ups from the voice PR (#70509). 1. macOS ARM64 onnx migration. Existing users who pinned openwakeword.inference_framework=onnx before the tflite fix landed kept a wake word that arms but never fires (ONNX's embedding model is broken on Apple Silicon, upstream #336). New resolve_inference_framework() honors an explicit framework everywhere ONNX actually works, but coerces the one provably-dead combination (explicit onnx + macOS ARM64) to tflite with a one-time warning. No config mutation; empty still falls back to the platform default. Both read sites (engine init + requirements check) route through the shared resolver. 2. Voice turn-timeout leak. Each listen cycle reassigned turnTimeoutRef without clearing the prior 60s timer, so a stale timer from an earlier cycle could fire handleTurn() mid-way through a later listen — after enough idle re-listens this wedged the loop into a non-re-arming state (the 'voice chat deactivates after ~a minute' report). Clear before re-arm. Tests: 64 wake tests (added onnx-coercion / intel-kept / tflite-kept / empty-default cases; updated the stale 'explicit onnx kept on ARM64' test that encoded the old broken behavior), 39 desktop voice/wake vitest, tsc + eslint clean.
This commit is contained in:
parent
533d633ab9
commit
0f64557c06
3 changed files with 91 additions and 9 deletions
|
|
@ -229,6 +229,12 @@ export function useVoiceConversation({
|
|||
onSilence: () => void handleTurn()
|
||||
})
|
||||
setStatus('listening')
|
||||
// Clear any prior turn-timeout before arming a fresh one. Each listen
|
||||
// cycle reassigns turnTimeoutRef; without clearing first, a stale 60s
|
||||
// timer from an earlier cycle survives and later fires handleTurn() in
|
||||
// the middle of a new listen, cutting it short (or, after enough idle
|
||||
// re-listens, wedging the loop into a state it doesn't re-arm from).
|
||||
clearTurnTimeout()
|
||||
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.couldNotStartSession)
|
||||
|
|
|
|||
|
|
@ -338,11 +338,11 @@ def test_default_framework_is_onnx_elsewhere(monkeypatch, plat, machine):
|
|||
assert ww.default_inference_framework() == "onnx"
|
||||
|
||||
|
||||
def test_explicit_framework_overrides_platform_default(monkeypatch):
|
||||
# An operator who pins a backend keeps it, even on macOS ARM64.
|
||||
def test_explicit_framework_kept_off_broken_platform(monkeypatch):
|
||||
# An operator who pins a backend keeps it everywhere ONNX actually works.
|
||||
calls = _install_fake_openwakeword(monkeypatch)
|
||||
monkeypatch.setattr(ww.sys, "platform", "darwin")
|
||||
monkeypatch.setattr("platform.machine", lambda: "arm64")
|
||||
monkeypatch.setattr(ww.sys, "platform", "linux")
|
||||
monkeypatch.setattr("platform.machine", lambda: "x86_64")
|
||||
ww._OpenWakeWordEngine(
|
||||
{"provider": "openwakeword", "openwakeword": {"inference_framework": "onnx"}}
|
||||
)
|
||||
|
|
@ -350,6 +350,47 @@ def test_explicit_framework_overrides_platform_default(monkeypatch):
|
|||
assert downloaded == [ww._bundled_wakeword_path("onnx")]
|
||||
|
||||
|
||||
def test_explicit_onnx_coerced_to_tflite_on_macos_arm64(monkeypatch):
|
||||
# The one exception: explicit onnx on macOS ARM64 is provably dead (ONNX's
|
||||
# embedding model never fires, upstream #336). Existing users who pinned it
|
||||
# before the tflite fix must not keep a wake word that arms but never fires.
|
||||
monkeypatch.setattr(ww.sys, "platform", "darwin")
|
||||
monkeypatch.setattr("platform.machine", lambda: "arm64")
|
||||
resolved = ww.resolve_inference_framework(
|
||||
{"openwakeword": {"inference_framework": "onnx"}}
|
||||
)
|
||||
assert resolved == "tflite"
|
||||
|
||||
|
||||
def test_explicit_onnx_kept_on_macos_intel(monkeypatch):
|
||||
# Intel Macs run ONNX fine — only ARM64 is broken, so don't coerce there.
|
||||
monkeypatch.setattr(ww.sys, "platform", "darwin")
|
||||
monkeypatch.setattr("platform.machine", lambda: "x86_64")
|
||||
resolved = ww.resolve_inference_framework(
|
||||
{"openwakeword": {"inference_framework": "onnx"}}
|
||||
)
|
||||
assert resolved == "onnx"
|
||||
|
||||
|
||||
def test_explicit_tflite_kept_on_macos_arm64(monkeypatch):
|
||||
monkeypatch.setattr(ww.sys, "platform", "darwin")
|
||||
monkeypatch.setattr("platform.machine", lambda: "arm64")
|
||||
resolved = ww.resolve_inference_framework(
|
||||
{"openwakeword": {"inference_framework": "tflite"}}
|
||||
)
|
||||
assert resolved == "tflite"
|
||||
|
||||
|
||||
def test_empty_framework_falls_back_to_platform_default(monkeypatch):
|
||||
monkeypatch.setattr(ww.sys, "platform", "darwin")
|
||||
monkeypatch.setattr("platform.machine", lambda: "arm64")
|
||||
assert ww.resolve_inference_framework({}) == "tflite"
|
||||
assert ww.resolve_inference_framework({"openwakeword": {"inference_framework": ""}}) == "tflite"
|
||||
monkeypatch.setattr(ww.sys, "platform", "linux")
|
||||
monkeypatch.setattr("platform.machine", lambda: "x86_64")
|
||||
assert ww.resolve_inference_framework({}) == "onnx"
|
||||
|
||||
|
||||
# ── ambient-speech rejection: consecutive-frame confirmation ──────────────────
|
||||
|
||||
def _openwakeword_engine_with_scores(monkeypatch, cfg_wake, scores):
|
||||
|
|
|
|||
|
|
@ -115,6 +115,44 @@ def default_inference_framework() -> str:
|
|||
return "tflite" if _is_macos_arm64() else "onnx"
|
||||
|
||||
|
||||
_warned_onnx_coerced = False
|
||||
|
||||
|
||||
def resolve_inference_framework(cfg: Dict[str, Any]) -> str:
|
||||
"""Resolve the effective openWakeWord backend from config.
|
||||
|
||||
Honors an explicit ``openwakeword.inference_framework`` — EXCEPT the one
|
||||
combination that is provably dead: an explicit ``onnx`` on macOS ARM64,
|
||||
where ONNX's embedding model never lets a phrase cross threshold (upstream
|
||||
#336). Existing macOS users who pinned ``onnx`` before the tflite fix landed
|
||||
would otherwise keep a wake word that arms but never fires. Coerce that one
|
||||
case to tflite (with a one-time warning) instead of silently shipping a dead
|
||||
ear. Every other explicit value is respected as-is; empty falls back to the
|
||||
platform default.
|
||||
"""
|
||||
global _warned_onnx_coerced
|
||||
|
||||
sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {}
|
||||
framework = str(sub.get("inference_framework") or "").strip().lower()
|
||||
|
||||
if not framework:
|
||||
return default_inference_framework()
|
||||
|
||||
if framework == "onnx" and _is_macos_arm64():
|
||||
if not _warned_onnx_coerced:
|
||||
_warned_onnx_coerced = True
|
||||
logger.warning(
|
||||
"wake: openwakeword.inference_framework='onnx' is set but ONNX's "
|
||||
"embedding model never fires on macOS ARM64 (openWakeWord #336) — "
|
||||
"using tflite instead. Set inference_framework to '' (auto) or "
|
||||
"'tflite' in config.yaml to silence this."
|
||||
)
|
||||
return "tflite"
|
||||
|
||||
return framework
|
||||
|
||||
|
||||
|
||||
def ensure_tflite_runtime() -> bool:
|
||||
"""Make ``import tflite_runtime.interpreter`` resolve, returning success.
|
||||
|
||||
|
|
@ -324,9 +362,7 @@ class _OpenWakeWordEngine(_Engine):
|
|||
|
||||
sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {}
|
||||
model_ref = str(sub.get("model") or _BUNDLED_MODEL_NAME).strip()
|
||||
framework = str(sub.get("inference_framework") or "").strip().lower()
|
||||
if not framework:
|
||||
framework = default_inference_framework()
|
||||
framework = resolve_inference_framework(cfg)
|
||||
# openWakeWord returns a 0..1 score per frame; sensitivity IS the raw
|
||||
# threshold a score must clear. Higher = stricter (fewer false fires).
|
||||
# Default 0.6 sits above openWakeWord's permissive 0.5 baseline, which
|
||||
|
|
@ -714,8 +750,7 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
|||
# Report it as a real remediation instead of arming a detector that can't fire.
|
||||
tflite_ok = True
|
||||
if provider not in ("porcupine", "sherpa", "sherpa-onnx", "kws", "open"):
|
||||
sub = cfg.get("openwakeword") if isinstance(cfg.get("openwakeword"), dict) else {}
|
||||
framework = str(sub.get("inference_framework") or "").strip().lower() or default_inference_framework()
|
||||
framework = resolve_inference_framework(cfg)
|
||||
if framework == "tflite":
|
||||
tflite_ok = ensure_tflite_runtime() or lazy_deps.is_available("wake.openwakeword.tflite") or lazy_ok
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue