diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 2340672ed9f..b2e54da3f2e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -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) diff --git a/tests/tools/test_wake_word.py b/tests/tools/test_wake_word.py index baca95ab91b..6257ead738e 100644 --- a/tests/tools/test_wake_word.py +++ b/tests/tools/test_wake_word.py @@ -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): diff --git a/tools/wake_word.py b/tools/wake_word.py index 9367127fe3f..143372830d3 100644 --- a/tools/wake_word.py +++ b/tools/wake_word.py @@ -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