Files
avatar/core/wake_word.py
T
lucianoandClaude Fable 5.1 ff79832c30 AvatarPy: assistente personale con volto 3D, voce, memoria e plugin
Riscrittura in Python dell'assistente Avatar con interfaccia HUD (derivata da Mark LIV, CC BY-NC 4.0, vedi NOTICE.md).
Tre motori (Claude API, server locale OpenAI-compatibile, Claude Code), voce Kokoro/macOS, Whisper MLX,
avatar 3D con sincronizzazione labiale, memoria per categorie, allegati con OCR, monitor con avvisi,
plugin per Calendario, Mail, Promemoria, Note, Musica, app, Mac, timer, meteo, contatti, Messaggi,
file, Comandi Rapidi, browser, Telegram, WhatsApp (archivio e tempo reale).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 16:21:39 +02:00

212 lines
8.6 KiB
Python

"""
Local wake-word detection for JARVIS ("Hey Jarvis").
Design goals:
• ZERO cost when the feature is off — openwakeword is imported ONLY inside
start()/install helpers, never at module load. If the user never enables
wake word, none of this touches the app.
• ZERO latency on the audio path — the microphone callback only ever does a
cheap, non-blocking queue push (feed()); the actual model inference runs in
this module's own background thread, so the real-time audio thread and the
Gemini stream are never slowed.
• Fully local & offline — audio fed here never leaves the machine; there is no
network call except the one-time model download the user triggers from the UI.
openwakeword ships small ONNX models (a few MB each) and runs comfortably on a
CPU. The pretrained wake phrase used here is "Hey Jarvis".
"""
from __future__ import annotations
import queue
import subprocess
import sys
import threading
from pathlib import Path
from typing import Callable
# Pretrained openwakeword model that listens for "Hey Jarvis".
WAKE_MODEL = "hey_jarvis"
# Score in [0,1]; above this counts as a detection. Tunable per environment.
DEFAULT_THRESHOLD = 0.5
# Mic frames arrive at 16 kHz int16; this is just the detector's input rate.
SAMPLE_RATE = 16000
def is_installed() -> bool:
"""True if the openwakeword package is importable (no model check)."""
try:
import importlib.util
return importlib.util.find_spec("openwakeword") is not None
except Exception:
return False
def is_ready() -> bool:
"""True if openwakeword is installed AND its model files are present on disk.
This is a cheap, DETERMINISTIC file-existence check. It deliberately does NOT
construct a Model to probe readiness — doing that is slow and, worse, can clash
with the detector's own Model when it's already running, which intermittently
returned False and made the UI flicker to 'not downloaded'. Never raises.
"""
if not is_installed():
return False
try:
import openwakeword
models_dir = Path(openwakeword.__file__).resolve().parent / "resources" / "models"
if not models_dir.is_dir():
return False
has_wake = (any(models_dir.glob(f"{WAKE_MODEL}*.onnx"))
or any(models_dir.glob(f"{WAKE_MODEL}*.tflite")))
has_mel = (any(models_dir.glob("melspectrogram*.onnx"))
or any(models_dir.glob("melspectrogram*.tflite")))
has_emb = (any(models_dir.glob("embedding_model*.onnx"))
or any(models_dir.glob("embedding_model*.tflite")))
return bool(has_wake and has_mel and has_emb)
except Exception:
return False
def install_and_download(logger: Callable[[str], None] = print,
notify: Callable[[str], None] | None = None) -> tuple[bool, str]:
"""
One-click setup for the UI button: pip-install openwakeword if missing, then
download the wake model. Returns (ok, message). Never raises — every failure
is reported through the returned message and the logger.
"""
_tell = notify or (lambda _msg: None)
try:
if not is_installed():
logger("Wake word: installing openwakeword (one-time)…")
_tell("Wake word: installing openwakeword (one-time)…")
r = subprocess.run(
[sys.executable, "-m", "pip", "install", "openwakeword"],
capture_output=True, text=True,
)
if r.returncode != 0:
tail = (r.stderr or r.stdout or "").strip().splitlines()[-1:] or [""]
return False, f"pip install failed: {tail[0][:160]}"
# Download the pretrained melspectrogram/embedding + wake models.
logger("Wake word: downloading models…")
_tell("Wake word: downloading models…")
try:
import openwakeword.utils as _u
try:
_u.download_models([WAKE_MODEL])
except TypeError:
_u.download_models() # older signature downloads the default set
except Exception as e:
return False, f"model download failed: {e}"
if not is_ready():
return False, "installed, but the wake model could not be loaded."
logger("Wake word: ready.")
return True, "Wake word installed and ready."
except Exception as e:
return False, f"setup error: {e}"
class WakeWordDetector:
"""
Runs the wake model in a dedicated thread. The mic thread calls feed() with
raw int16 frames; detections invoke on_detect() (called from this thread —
the callback must marshal to whatever loop/UI it needs).
"""
def __init__(self, on_detect: Callable[[], None],
threshold: float = DEFAULT_THRESHOLD,
logger: Callable[[str], None] = print,
notify: Callable[[str], None] | None = None):
self._on_detect = on_detect
self._threshold = threshold
self._logger = logger
# See PluginRegistry: `logger` is the console and gets everything,
# `notify` is the activity log and gets only what the user must act on.
self._notify = notify or (lambda _msg: None)
self._queue: queue.Queue = queue.Queue(maxsize=50)
self._thread: threading.Thread | None = None
self._running = False
self._model = None
self._ready = False
def start(self) -> bool:
"""Load the model and spawn the inference thread. Returns True on success.
Safe to call again — a no-op if already running. Never raises."""
if self._running:
return True
try:
from openwakeword.model import Model
self._model = Model(wakeword_models=[WAKE_MODEL], inference_framework="onnx")
except Exception as e:
self._logger(f"Wake word: could not load model — {e}")
self._notify("Wake word unavailable — use the WAKE NOW button.")
self._model = None
return False
self._running = True
self._ready = True
self._thread = threading.Thread(target=self._loop, daemon=True, name="WakeWordThread")
self._thread.start()
self._logger("Wake word: listening for 'Hey Jarvis'.")
return True
def stop(self) -> None:
self._running = False
# unblock the thread if it's waiting on the queue
try:
self._queue.put_nowait(None)
except Exception:
pass
self._model = None
self._ready = False
@property
def ready(self) -> bool:
return self._ready
def feed(self, frame_int16) -> None:
"""Called from the mic callback (real-time thread). Must stay cheap and
never block — the frame is copied and dropped if the queue is backed up."""
if not self._running:
return
try:
# frame_int16 is a numpy int16 array (possibly 2-D mono) — flatten to 1-D
data = frame_int16[:, 0].copy() if getattr(frame_int16, "ndim", 1) > 1 else frame_int16.copy()
self._queue.put_nowait(data)
except queue.Full:
pass
except Exception:
pass
def _loop(self) -> None:
import numpy as np
while self._running:
try:
frame = self._queue.get()
if frame is None or not self._running:
break
scores = self._model.predict(np.asarray(frame, dtype=np.int16))
score = 0.0
if isinstance(scores, dict):
# match the jarvis model regardless of exact key suffix
for k, v in scores.items():
if "jarvis" in k.lower():
score = max(score, float(v))
if score == 0.0 and scores:
score = max(float(v) for v in scores.values())
if score >= self._threshold:
# drain any backlog so we don't double-fire on the same utterance
self._drain()
try:
self._on_detect()
except Exception as e:
self._logger(f"Wake word: on_detect error — {e}")
except Exception as e:
self._logger(f"Wake word: inference error — {e}")
def _drain(self) -> None:
try:
while True:
self._queue.get_nowait()
except Exception:
pass