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:
Teknium 2026-07-28 18:11:17 -07:00
parent 533d633ab9
commit 0f64557c06
3 changed files with 91 additions and 9 deletions

View file

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

View file

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

View file

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