refactor(tools): unify wake_word singleton teardown, tighten docstrings across slice A
This commit is contained in:
@@ -68,12 +68,9 @@ def _configured_stop_phrases() -> tuple:
|
||||
|
||||
|
||||
def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) -> bool:
|
||||
"""True when *transcript* is EXACTLY a configured stop phrase.
|
||||
|
||||
Deliberately strict: the whole utterance — lowercased, surrounding punctuation
|
||||
stripped — must equal a phrase, so "stop doing that and try again" still reaches
|
||||
the agent. ``voice.stop_phrases: []`` disables.
|
||||
"""
|
||||
"""True when *transcript* is EXACTLY a configured stop phrase. Deliberately strict: the whole
|
||||
utterance — lowercased, surrounding punctuation stripped — must equal a phrase, so "stop doing
|
||||
that and try again" still reaches the agent. ``voice.stop_phrases: []`` disables."""
|
||||
cleaned = transcript.strip().lower().strip(".,!?;: \t\n\"'") if transcript else ""
|
||||
if not cleaned:
|
||||
return False
|
||||
@@ -126,11 +123,8 @@ def is_tts_echo(transcript: str, spoken_text: str,
|
||||
|
||||
|
||||
def voice_stop_hint() -> str:
|
||||
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start.
|
||||
|
||||
Uses the first ``voice.stop_phrases`` entry so a custom phrase renders correctly;
|
||||
"" when stop phrases are disabled. Every surface announcing voice-mode start (CLI,
|
||||
TUI, desktop) uses this one owner instead of hardcoding the wording.
|
||||
"""
|
||||
"""One-line 'Say "stop" to end the voice chat.' hint for voice-mode start, using the first
|
||||
``voice.stop_phrases`` entry ("" when disabled). Every surface announcing voice-mode start
|
||||
(CLI, TUI, desktop) uses this one owner instead of hardcoding the wording."""
|
||||
phrases = _configured_stop_phrases()
|
||||
return f'Say "{phrases[0]}" to end the voice chat.' if phrases else ""
|
||||
|
||||
+62
-125
@@ -58,18 +58,12 @@ class WakeWordInUse(RuntimeError):
|
||||
|
||||
# ── Config ──
|
||||
|
||||
# capture: "local" (PortAudio on the backend host), "client" (desktop/TUI streams int16
|
||||
# frames via wake.feed), or "auto" (local when a device exists, else client).
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"enabled": False,
|
||||
"surface": "auto",
|
||||
"input_device": None,
|
||||
# capture: "local" (PortAudio on the backend host), "client" (desktop/TUI streams
|
||||
# int16 frames via wake.feed), or "auto" (local when a device exists, else client).
|
||||
"capture": "auto",
|
||||
"provider": "openwakeword",
|
||||
"phrase": "hey hermes",
|
||||
"sensitivity": 0.6,
|
||||
"confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES,
|
||||
"start_new_session": True,
|
||||
"enabled": False, "surface": "auto", "input_device": None, "capture": "auto",
|
||||
"provider": "openwakeword", "phrase": "hey hermes", "sensitivity": 0.6,
|
||||
"confirmation_frames": _DEFAULT_CONFIRMATION_FRAMES, "start_new_session": True,
|
||||
}
|
||||
|
||||
# Bundled "hey hermes" model (tools/wakewords/) — the default; alias names resolve
|
||||
@@ -86,7 +80,6 @@ def _bundled_wakeword_path(framework: str = "onnx") -> str:
|
||||
|
||||
def _is_macos_arm64() -> bool:
|
||||
import platform
|
||||
|
||||
return sys.platform == "darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
@@ -123,14 +116,11 @@ def resolve_inference_framework(cfg: Dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def ensure_tflite_runtime() -> bool:
|
||||
"""Make ``import tflite_runtime.interpreter`` resolve, returning success.
|
||||
|
||||
openWakeWord hardcodes that import but only declares ``tflite-runtime`` on Linux;
|
||||
on macOS the wheel is ``ai-edge-litert``. Alias it in-process (site-packages untouched).
|
||||
"""
|
||||
"""Make ``import tflite_runtime.interpreter`` resolve, returning success. openWakeWord hardcodes
|
||||
that import but only declares ``tflite-runtime`` on Linux; on macOS the wheel is ``ai-edge-litert``,
|
||||
so alias it in-process (site-packages untouched)."""
|
||||
try:
|
||||
import tflite_runtime.interpreter # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -139,7 +129,6 @@ def ensure_tflite_runtime() -> bool:
|
||||
except ImportError:
|
||||
return False
|
||||
import types
|
||||
|
||||
pkg = types.ModuleType("tflite_runtime")
|
||||
pkg.__path__ = [] # type: ignore[attr-defined] # mark as package
|
||||
sys.modules.setdefault("tflite_runtime", pkg)
|
||||
@@ -152,7 +141,6 @@ def load_wake_word_config() -> Dict[str, Any]:
|
||||
"""Return the ``wake_word`` config section, shape-guarded to a dict."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config().get("wake_word")
|
||||
except Exception:
|
||||
cfg = None
|
||||
@@ -202,13 +190,10 @@ def wake_phrase(cfg: Optional[Dict[str, Any]] = None) -> str:
|
||||
|
||||
def resolve_capture_mode(cfg: Optional[Dict[str, Any]] = None, *, prefer_client: bool = False,
|
||||
force_local: bool = False) -> str:
|
||||
"""Return ``local`` or ``client`` capture mode for this arm.
|
||||
|
||||
``prefer_client`` is set by remote desktop; ``force_local`` keeps CLI/TUI on the
|
||||
process mic. Under ``auto`` a working backend input always wins; client is the
|
||||
fallback only for a preferring surface with no usable backend mic — CLI/TUI stay
|
||||
local so status reports the real requirement rather than a path nothing will feed.
|
||||
"""
|
||||
"""Return ``local`` or ``client`` capture mode for this arm. ``prefer_client`` is set by remote
|
||||
desktop; ``force_local`` keeps CLI/TUI on the process mic. Under ``auto`` a working backend input
|
||||
always wins; client is the fallback only for a preferring surface with no usable backend mic —
|
||||
CLI/TUI stay local so status reports the real requirement rather than a path nothing will feed."""
|
||||
cfg = cfg if cfg is not None else load_wake_word_config()
|
||||
if force_local:
|
||||
return "local"
|
||||
@@ -231,6 +216,9 @@ def _local_input_device_ready() -> bool:
|
||||
"""True when PortAudio is importable and at least one input device exists."""
|
||||
try:
|
||||
sd, _ = _import_audio()
|
||||
except (ImportError, OSError):
|
||||
return False
|
||||
try:
|
||||
devices = sd.query_devices()
|
||||
if isinstance(devices, dict):
|
||||
return _input_channels(devices) > 0
|
||||
@@ -243,11 +231,8 @@ def _local_input_device_ready() -> bool:
|
||||
|
||||
|
||||
def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Should ``surface`` (``cli`` / ``tui`` / ``gui``) host the listener?
|
||||
|
||||
True when enabled and the configured ``surface`` is ``auto`` or this exact surface.
|
||||
``auto`` only makes a surface eligible; the ownership lock still admits one claimant.
|
||||
"""
|
||||
"""Should ``surface`` (cli/tui/gui) host the listener? True when enabled and the configured
|
||||
surface is ``auto`` or this one; ``auto`` only makes it eligible — the lock admits one claimant."""
|
||||
cfg = cfg if cfg is not None else load_wake_word_config()
|
||||
if not cfg.get("enabled"):
|
||||
return False
|
||||
@@ -260,24 +245,19 @@ def wake_surface_enabled(surface: str, cfg: Optional[Dict[str, Any]] = None) ->
|
||||
def _active_profile_name() -> str:
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
return get_active_profile_name() or "default"
|
||||
except Exception:
|
||||
return "default"
|
||||
|
||||
|
||||
def enrolled_profile_phrases() -> Dict[str, str]:
|
||||
"""Map ``profile name -> wake phrase`` for every wake-enabled profile.
|
||||
|
||||
Reads each profile's own ``config.yaml`` raw (``load_config()`` targets only the
|
||||
ACTIVE profile). Phrase defaults to ``"hey <profile>"``; the sherpa engine listens
|
||||
for all of them and routes the wake to the matching profile. Unreadable → skipped.
|
||||
"""
|
||||
"""Map ``profile name -> wake phrase`` for every wake-enabled profile, reading each profile's own
|
||||
``config.yaml`` raw (``load_config()`` targets only the ACTIVE profile). Phrase defaults to
|
||||
``"hey <profile>"``; the sherpa engine listens for all and routes to the match. Unreadable → skipped."""
|
||||
phrases: Dict[str, str] = {}
|
||||
try:
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
from hermes_cli.profiles import get_profile_dir, list_profiles
|
||||
|
||||
for info in list_profiles():
|
||||
name = getattr(info, "name", None) or str(info)
|
||||
try:
|
||||
@@ -299,7 +279,6 @@ def enrolled_profile_phrases() -> Dict[str, str]:
|
||||
def _import_audio():
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
return sd, np
|
||||
|
||||
|
||||
@@ -312,10 +291,8 @@ def _audio_available() -> bool:
|
||||
|
||||
|
||||
def _describe_input_device(sd, selector: int | str | None) -> Dict[str, Any]:
|
||||
"""Resolve a PortAudio selector into JSON-safe diagnostics.
|
||||
|
||||
Diagnostic only: ``InputStream`` stays the authority on whether the device opens.
|
||||
"""
|
||||
"""Resolve a PortAudio selector into JSON-safe diagnostics (``InputStream`` stays the
|
||||
authority on whether the device actually opens)."""
|
||||
details: Dict[str, Any] = {"selector": selector}
|
||||
try:
|
||||
info = sd.query_devices(selector, "input")
|
||||
@@ -410,7 +387,6 @@ def _stt_ready() -> bool:
|
||||
mic but every utterance dies at transcription — same bar as ``check_voice_requirements``.)"""
|
||||
try:
|
||||
from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled
|
||||
|
||||
stt_config = _load_stt_config()
|
||||
return is_stt_enabled(stt_config) and _get_provider(stt_config) != "none"
|
||||
except Exception:
|
||||
@@ -421,15 +397,11 @@ _LAZY_TTS_FEATURES = {"edge": "tts.edge", "elevenlabs": "tts.elevenlabs", "mistr
|
||||
|
||||
|
||||
def _tts_ready() -> bool:
|
||||
"""Can the configured TTS provider run (or install at first use)?
|
||||
|
||||
PROBE, not an installer: ``check_tts_requirements`` lazily pip-installs the provider
|
||||
SDK, which froze wake.status polls for a whole pip run. Uninstalled deps count as
|
||||
ready iff lazy installs are allowed; pip is never touched from here.
|
||||
"""
|
||||
"""Can the configured TTS provider run (or install at first use)? PROBE, not an installer:
|
||||
``check_tts_requirements`` lazily pip-installs the SDK, which froze wake.status polls for a whole
|
||||
pip run. Uninstalled deps count as ready iff lazy installs are allowed; pip is never touched here."""
|
||||
try:
|
||||
from tools.tts_tool import _get_provider, _load_tts_config
|
||||
|
||||
provider = _get_provider(_load_tts_config())
|
||||
except Exception:
|
||||
return False
|
||||
@@ -437,14 +409,12 @@ def _tts_ready() -> bool:
|
||||
if feature is not None:
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
|
||||
if not lazy_deps.is_available(feature):
|
||||
return lazy_deps._allow_lazy_installs()
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
from tools.tts_tool import check_tts_requirements
|
||||
|
||||
return bool(check_tts_requirements())
|
||||
except Exception:
|
||||
return False
|
||||
@@ -455,7 +425,6 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
cfg = cfg if cfg is not None else load_wake_word_config()
|
||||
provider = _provider(cfg)
|
||||
from tools import lazy_deps
|
||||
|
||||
feature = _PROVIDERS.get(provider, ("", "wake.openwakeword"))[1]
|
||||
deps_ok = lazy_deps.is_available(feature)
|
||||
lazy_ok = lazy_deps._allow_lazy_installs()
|
||||
@@ -501,17 +470,11 @@ def check_wake_word_requirements(cfg: Optional[Dict[str, Any]] = None) -> Dict[s
|
||||
)
|
||||
|
||||
return {
|
||||
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok,
|
||||
"provider": provider,
|
||||
"deps_available": deps_ok,
|
||||
"audio_available": audio_ok,
|
||||
"available": key_ok and stt_ok and tts_ok and tflite_ok and mic_ok, "provider": provider,
|
||||
"deps_available": deps_ok, "audio_available": audio_ok,
|
||||
"local_input_available": _local_input_device_ready() if deps_ok else False,
|
||||
"capture": capture_mode,
|
||||
"access_key_set": key_ok,
|
||||
"stt_available": stt_ok,
|
||||
"tts_available": tts_ok,
|
||||
"phrase": wake_phrase(cfg),
|
||||
"hint": hint,
|
||||
"capture": capture_mode, "access_key_set": key_ok, "stt_available": stt_ok, "tts_available": tts_ok,
|
||||
"phrase": wake_phrase(cfg), "hint": hint,
|
||||
}
|
||||
|
||||
|
||||
@@ -546,11 +509,8 @@ class _Capture:
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
"""Background hotword listener. Fires ``on_wake()`` when the phrase is heard.
|
||||
|
||||
The engine is built once and kept alive across pause/resume; only the audio stream
|
||||
+ reader thread cycle, so toggling the mic for a voice turn is cheap.
|
||||
"""
|
||||
"""Background hotword listener; fires ``on_wake()`` when the phrase is heard. The engine is built
|
||||
once and kept across pause/resume — only the stream + reader thread cycle, so mic toggles are cheap."""
|
||||
|
||||
def __init__(self, engine: _Engine, on_wake: Callable[[], None],
|
||||
cooldown: float = _FIRE_COOLDOWN_SECONDS,
|
||||
@@ -558,28 +518,20 @@ class WakeWordDetector:
|
||||
input_device: int | str | None = None,
|
||||
external_audio: bool = False):
|
||||
import queue as _queue
|
||||
|
||||
self.engine = engine
|
||||
self.on_wake = on_wake
|
||||
self.cooldown = cooldown
|
||||
self.on_failure = on_failure
|
||||
self.input_device = input_device
|
||||
self.external_audio = bool(external_audio)
|
||||
self.engine, self.on_wake, self.cooldown, self.on_failure = engine, on_wake, cooldown, on_failure
|
||||
self.input_device, self.external_audio = input_device, bool(external_audio)
|
||||
self.input_device_details: Dict[str, Any] = (
|
||||
{"selector": "client", "name": "client capture", "hostapi": "remote"}
|
||||
if self.external_audio else {"selector": input_device}
|
||||
)
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._callback_inflight = threading.Event()
|
||||
self._last_fire = 0.0
|
||||
self._lock = threading.Lock()
|
||||
self._stop, self._callback_inflight = threading.Event(), threading.Event()
|
||||
self._lock, self._last_fire = threading.Lock(), 0.0
|
||||
# Client-capture PCM queue (int16 mono frames). Local mode ignores this.
|
||||
self._audio_q: "_queue.Queue[Any]" = _queue.Queue(maxsize=64)
|
||||
# True when the stream is open but every frame is (near-)silence, so status
|
||||
# surfaces can tell "armed" from "deaf".
|
||||
self.audio_silent = False
|
||||
self._silent_frames = 0
|
||||
self.audio_silent, self._silent_frames = False, 0
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
@@ -587,11 +539,8 @@ class WakeWordDetector:
|
||||
return t is not None and t.is_alive()
|
||||
|
||||
def feed(self, pcm_int16) -> None:
|
||||
"""Enqueue one int16 mono frame (or raw bytes) for client capture.
|
||||
|
||||
Short frames are zero-padded to ``engine.frame_length``; long frames are split.
|
||||
On queue overflow the oldest frame is dropped to stay real-time.
|
||||
"""
|
||||
"""Enqueue one int16 mono frame (or raw bytes) for client capture. Short frames are
|
||||
zero-padded to ``engine.frame_length``, long ones split; on overflow the oldest is dropped."""
|
||||
if not self.external_audio:
|
||||
return
|
||||
try:
|
||||
@@ -608,9 +557,7 @@ class WakeWordDetector:
|
||||
for offset in range(0, int(arr.shape[0]), fl):
|
||||
chunk = arr[offset : offset + fl]
|
||||
if chunk.shape[0] < fl:
|
||||
pad = np.zeros(fl, dtype=np.int16)
|
||||
pad[: chunk.shape[0]] = chunk
|
||||
chunk = pad
|
||||
chunk = np.pad(chunk, (0, fl - chunk.shape[0]))
|
||||
try:
|
||||
self._audio_q.put_nowait(chunk)
|
||||
except Exception:
|
||||
@@ -686,12 +633,10 @@ class WakeWordDetector:
|
||||
details = self.input_device_details = _describe_input_device(sd, self.input_device)
|
||||
cap = _Capture(np=np, rate=_capture_sample_rate(details))
|
||||
cap.frame_length = max(1, int(round(frame_length * cap.rate / SAMPLE_RATE)))
|
||||
logger.info(
|
||||
"wake word: opening microphone device=%s selector=%r hostapi=%s "
|
||||
"default_rate=%s capture_rate=%d engine_rate=%d",
|
||||
details.get("name") or "system default", self.input_device, details.get("hostapi") or "unknown",
|
||||
details.get("default_samplerate") or "unknown", cap.rate, SAMPLE_RATE,
|
||||
)
|
||||
logger.info("wake word: opening microphone device=%s selector=%r hostapi=%s "
|
||||
"default_rate=%s capture_rate=%d engine_rate=%d", details.get("name") or "system default",
|
||||
self.input_device, details.get("hostapi") or "unknown",
|
||||
details.get("default_samplerate") or "unknown", cap.rate, SAMPLE_RATE)
|
||||
try:
|
||||
cap.stream = sd.InputStream(device=self.input_device, samplerate=cap.rate, channels=1,
|
||||
dtype="int16", blocksize=cap.frame_length)
|
||||
@@ -791,7 +736,6 @@ _detector_lock = threading.Lock()
|
||||
|
||||
def _lock_path() -> Path:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
return get_default_hermes_root() / "runtime" / "wake-word.lock"
|
||||
|
||||
|
||||
@@ -799,7 +743,6 @@ def _flock(handle, acquire: bool) -> None:
|
||||
"""Non-blocking exclusive lock (or unlock) of one byte / whole file, per OS."""
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
if acquire: # msvcrt needs at least one byte to lock
|
||||
handle.seek(0, os.SEEK_END)
|
||||
if handle.tell() == 0:
|
||||
@@ -809,7 +752,6 @@ def _flock(handle, acquire: bool) -> None:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK if acquire else msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), (fcntl.LOCK_EX | fcntl.LOCK_NB) if acquire else fcntl.LOCK_UN)
|
||||
|
||||
|
||||
@@ -850,26 +792,27 @@ def _owned_detector(owner: object) -> Optional[WakeWordDetector]:
|
||||
return _detector if _detector is not None and _detector_owner is owner else None
|
||||
|
||||
|
||||
def _teardown_locked(close: Callable[[], None]) -> None:
|
||||
"""Forget the singleton and run ``close``, always releasing the machine lease (caller holds the lock)."""
|
||||
_, lock_handle = _clear_singleton_locked()
|
||||
try:
|
||||
close()
|
||||
finally:
|
||||
_release_machine_lock(lock_handle)
|
||||
|
||||
|
||||
def _detector_failed(detector: WakeWordDetector) -> None:
|
||||
"""Release ownership if the active microphone stream dies unexpectedly."""
|
||||
with _detector_lock:
|
||||
if _detector is not detector:
|
||||
return
|
||||
_, lock_handle = _clear_singleton_locked()
|
||||
try:
|
||||
detector.engine.close()
|
||||
finally:
|
||||
_release_machine_lock(lock_handle)
|
||||
if _detector is detector:
|
||||
_teardown_locked(detector.engine.close)
|
||||
|
||||
|
||||
def start_listening(on_wake: Callable[[], None], *, owner: object, config: Optional[Dict[str, Any]] = None,
|
||||
external_audio: bool = False) -> WakeWordDetector:
|
||||
"""Claim, build, and start the detector. Idempotent for the same owner.
|
||||
|
||||
Raises if engine construction fails (missing deps / access key / model); callers
|
||||
should probe :func:`check_wake_word_requirements` first. A different owner,
|
||||
including another process, receives :class:`WakeWordInUse`.
|
||||
"""
|
||||
"""Claim, build, and start the detector. Idempotent for the same owner; a different owner
|
||||
(or process) gets :class:`WakeWordInUse`. Raises if engine construction fails (missing deps /
|
||||
access key / model) — callers should probe :func:`check_wake_word_requirements` first."""
|
||||
if owner is None:
|
||||
raise ValueError("wake-word owner must not be None")
|
||||
|
||||
@@ -927,13 +870,10 @@ def resume_listening(*, owner: object) -> bool:
|
||||
def stop_listening(*, owner: object) -> bool:
|
||||
"""Fully stop the detector only when ``owner`` holds the lease."""
|
||||
with _detector_lock:
|
||||
if _owned_detector(owner) is None:
|
||||
det = _owned_detector(owner)
|
||||
if det is None:
|
||||
return False
|
||||
det, lock_handle = _clear_singleton_locked()
|
||||
try:
|
||||
det.stop()
|
||||
finally:
|
||||
_release_machine_lock(lock_handle)
|
||||
_teardown_locked(det.stop)
|
||||
return True
|
||||
|
||||
|
||||
@@ -990,8 +930,5 @@ def detector_frame_info() -> Dict[str, Any]:
|
||||
det = _current_detector()
|
||||
if det is None:
|
||||
return {"sample_rate": SAMPLE_RATE, "frame_length": 1280}
|
||||
return {
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"frame_length": int(getattr(det.engine, "frame_length", 1280) or 1280),
|
||||
"external_audio": bool(det.external_audio),
|
||||
}
|
||||
return {"sample_rate": SAMPLE_RATE, "external_audio": bool(det.external_audio),
|
||||
"frame_length": int(getattr(det.engine, "frame_length", 1280) or 1280)}
|
||||
|
||||
+15
-41
@@ -17,13 +17,11 @@ logger = logging.getLogger("tools.wake_word")
|
||||
|
||||
def _ww():
|
||||
from tools import wake_word
|
||||
|
||||
return wake_word
|
||||
|
||||
|
||||
def _ensure_dep(feature: str) -> None:
|
||||
from tools import lazy_deps
|
||||
|
||||
lazy_deps.ensure(feature, prompt=False)
|
||||
|
||||
|
||||
@@ -56,12 +54,9 @@ def _sub(cfg: Dict[str, Any], key: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
class _OpenWakeWordEngine(_Engine):
|
||||
"""openWakeWord — free, local ONNX/tflite hotword detection.
|
||||
|
||||
Scores one ~80 ms frame at a time; ``sensitivity`` IS the raw 0..1 threshold
|
||||
(higher = stricter). A real utterance holds the score high across frames while a
|
||||
stray phoneme spikes one, so ``confirmation_frames`` consecutive hits are required.
|
||||
"""
|
||||
"""openWakeWord — free, local ONNX/tflite hotword detection. Scores one ~80 ms frame at a time;
|
||||
``sensitivity`` IS the raw 0..1 threshold (higher = stricter). A real utterance holds the score
|
||||
high across frames while a stray phoneme spikes one, so ``confirmation_frames`` hits are required."""
|
||||
|
||||
frame_length = 1280 # openWakeWord recommends 80 ms frames.
|
||||
|
||||
@@ -69,7 +64,6 @@ class _OpenWakeWordEngine(_Engine):
|
||||
_ensure_dep("wake.openwakeword")
|
||||
import openwakeword
|
||||
from openwakeword.model import Model
|
||||
|
||||
ww = _ww()
|
||||
model_ref = str(_sub(cfg, "openwakeword").get("model") or ww._BUNDLED_MODEL_NAME).strip()
|
||||
framework = self._usable_framework(ww.resolve_inference_framework(cfg))
|
||||
@@ -90,13 +84,9 @@ class _OpenWakeWordEngine(_Engine):
|
||||
|
||||
@staticmethod
|
||||
def _usable_framework(framework: str) -> str:
|
||||
"""Refuse openWakeWord's silent tflite→onnx downgrade.
|
||||
|
||||
Without a tflite runtime openWakeWord falls back to onnx, which on macOS ARM64
|
||||
never fires — the listener would arm and stay deaf. Install + bridge the runtime
|
||||
first (the platform gate lives here because dep specs can't carry PEP 508
|
||||
markers); on that Mac raise instead of downgrading.
|
||||
"""
|
||||
"""Refuse openWakeWord's silent tflite→onnx downgrade: without a tflite runtime it falls back
|
||||
to onnx, which on macOS ARM64 never fires (armed but deaf). Install + bridge the runtime first
|
||||
(gate lives here because dep specs can't carry PEP 508 markers); on that Mac raise instead."""
|
||||
ww = _ww()
|
||||
if framework != "tflite" or ww.ensure_tflite_runtime():
|
||||
return framework
|
||||
@@ -107,10 +97,8 @@ class _OpenWakeWordEngine(_Engine):
|
||||
if ww.ensure_tflite_runtime():
|
||||
return framework
|
||||
if ww._is_macos_arm64():
|
||||
raise RuntimeError(
|
||||
"The wake word needs the tflite backend on this Mac, but its "
|
||||
"runtime is missing. Install it with: pip install ai-edge-litert"
|
||||
)
|
||||
raise RuntimeError("The wake word needs the tflite backend on this Mac, but its "
|
||||
"runtime is missing. Install it with: pip install ai-edge-litert")
|
||||
logger.warning("wake word: no tflite runtime available — falling back to onnx")
|
||||
return "onnx"
|
||||
|
||||
@@ -149,7 +137,6 @@ _SHERPA_KWS_MODEL_DIR = "sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01"
|
||||
|
||||
def _sherpa_model_root() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "cache" / "wakewords"
|
||||
|
||||
|
||||
@@ -161,7 +148,6 @@ def _ensure_sherpa_model(root: Optional[Path] = None) -> Path:
|
||||
return target
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
archive = root / f"{_SHERPA_KWS_MODEL_DIR}.tar.bz2"
|
||||
logger.info("wake word: downloading sherpa KWS model (one-time, ~13 MB)")
|
||||
@@ -175,11 +161,8 @@ def _ensure_sherpa_model(root: Optional[Path] = None) -> Path:
|
||||
|
||||
|
||||
class _SherpaKwsEngine(_Engine):
|
||||
"""sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training.
|
||||
|
||||
``wake_word.phrase`` is BPE-tokenized at runtime against the model's vocabulary,
|
||||
so here ``phrase`` is DETECTION config, not a cosmetic label.
|
||||
"""
|
||||
"""sherpa-onnx open-vocabulary keyword spotting — any typed phrase, zero training. ``wake_word.phrase``
|
||||
is BPE-tokenized at runtime against the model's vocabulary: DETECTION config, not a cosmetic label."""
|
||||
|
||||
frame_length = 1280 # streaming zipformer accepts any chunk; match capture path.
|
||||
|
||||
@@ -188,7 +171,6 @@ class _SherpaKwsEngine(_Engine):
|
||||
import sherpa_onnx
|
||||
import tempfile
|
||||
from sherpa_onnx import text2token
|
||||
|
||||
ww = _ww()
|
||||
model_dir = str(_sub(cfg, "sherpa").get("model_dir") or "").strip()
|
||||
d = Path(model_dir) if model_dir else _ensure_sherpa_model()
|
||||
@@ -230,19 +212,14 @@ class _SherpaKwsEngine(_Engine):
|
||||
return str(hits[0])
|
||||
|
||||
self._spotter = sherpa_onnx.KeywordSpotter(
|
||||
tokens=str(d / "tokens.txt"),
|
||||
encoder=_model_file("encoder-*[!8].onnx"),
|
||||
decoder=_model_file("decoder-*[!8].onnx"),
|
||||
joiner=_model_file("joiner-*[!8].onnx"),
|
||||
keywords_file=self._keywords_file,
|
||||
keywords_threshold=threshold,
|
||||
num_threads=1,
|
||||
tokens=str(d / "tokens.txt"), encoder=_model_file("encoder-*[!8].onnx"),
|
||||
decoder=_model_file("decoder-*[!8].onnx"), joiner=_model_file("joiner-*[!8].onnx"),
|
||||
keywords_file=self._keywords_file, keywords_threshold=threshold, num_threads=1,
|
||||
)
|
||||
self._stream = self._spotter.create_stream()
|
||||
|
||||
def process(self, frame) -> bool:
|
||||
import numpy as np
|
||||
|
||||
self._stream.accept_waveform(_ww().SAMPLE_RATE, np.asarray(frame, dtype=np.float32) / 32768.0)
|
||||
fired = False
|
||||
while self._spotter.is_ready(self._stream):
|
||||
@@ -276,13 +253,10 @@ class _PorcupineEngine(_Engine):
|
||||
def __init__(self, cfg: Dict[str, Any]):
|
||||
_ensure_dep("wake.porcupine")
|
||||
import pvporcupine
|
||||
|
||||
access_key = (os.getenv("PORCUPINE_ACCESS_KEY") or "").strip()
|
||||
if not access_key:
|
||||
raise RuntimeError(
|
||||
"Porcupine wake word requires PORCUPINE_ACCESS_KEY "
|
||||
"(get a free key at https://console.picovoice.ai)."
|
||||
)
|
||||
raise RuntimeError("Porcupine wake word requires PORCUPINE_ACCESS_KEY "
|
||||
"(get a free key at https://console.picovoice.ai).")
|
||||
keyword = str(_sub(cfg, "porcupine").get("keyword") or "jarvis").strip()
|
||||
# Porcupine's `sensitivities` runs the OPPOSITE way to our shared knob (higher =
|
||||
# looser); invert so "higher = stricter" holds for every engine.
|
||||
|
||||
+7
-13
@@ -28,13 +28,10 @@ VALID_MODES = tuple(_MODE_ARGS)
|
||||
|
||||
|
||||
def _run(args: List[str], cwd: str, timeout: int = _GIT_TIMEOUT):
|
||||
"""Run git, returning (returncode, stdout). Never raises on git failure.
|
||||
|
||||
Hardened against a malicious repo's ``.git/config`` (GHSA-7x36-8jrh-v4pw):
|
||||
``noninteractive_git_env`` disables fsmonitor/hooks/pager/editor/credential sinks,
|
||||
and ``harden_git_argv`` appends ``--no-ext-diff --no-textconv`` to diff-rendering
|
||||
subcommands so attribute-scoped diff/textconv drivers can't execute either.
|
||||
"""
|
||||
"""Run git, returning (returncode, stdout). Never raises on git failure. Hardened against a
|
||||
malicious repo's ``.git/config`` (GHSA-7x36-8jrh-v4pw): ``noninteractive_git_env`` disables
|
||||
fsmonitor/hooks/pager/editor/credential sinks and ``harden_git_argv`` appends ``--no-ext-diff
|
||||
--no-textconv`` to diff-rendering subcommands so attribute-scoped drivers can't execute either."""
|
||||
proc = subprocess.run(
|
||||
["git", "-c", "core.quotePath=false", *harden_git_argv(args)],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace",
|
||||
@@ -65,12 +62,9 @@ def _untracked_diff(cwd: str, files: List[str]) -> str:
|
||||
|
||||
|
||||
def collect_working_diff(cwd: str, mode: str = "working", paths: List[str] | None = None) -> Dict:
|
||||
"""Collect a git diff of the working directory.
|
||||
|
||||
Returns ``{"success", "stat", "diff", "untracked", "empty"}`` on success or
|
||||
``{"success": False, "error": ...}`` when git is unavailable / not a repo. ``paths``
|
||||
restricts the diff to pathspecs (passed verbatim); untracked files are then skipped.
|
||||
"""
|
||||
"""Collect a git diff of the working directory: ``{"success", "stat", "diff", "untracked", "empty"}``
|
||||
on success or ``{"success": False, "error": ...}`` when git is unavailable / not a repo. ``paths``
|
||||
restricts the diff to pathspecs (passed verbatim); untracked files are then skipped."""
|
||||
if mode not in _MODE_ARGS:
|
||||
return {"success": False, "error": f"Unknown mode '{mode}'. Use: {', '.join(VALID_MODES)}"}
|
||||
if not shutil.which("git"):
|
||||
|
||||
+21
-38
@@ -34,6 +34,7 @@ _SUBSYSTEMS = (MEMORY, SKILLS)
|
||||
# Per-subsystem config key. Intentionally a single boolean with no "block all writes"
|
||||
# state — to disable a subsystem use its own enable flag (e.g. ``memory.memory_enabled``).
|
||||
CONFIG_KEY = "write_approval"
|
||||
_TRUTHY_STRINGS = frozenset({"on", "true", "yes", "1", "approve", "enabled"})
|
||||
|
||||
|
||||
# --- Config resolution ---
|
||||
@@ -55,9 +56,7 @@ def _normalize_enabled(value: Any) -> bool:
|
||||
covers hand-edited configs (YAML already parses bare on/off/yes/no)."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"on", "true", "yes", "1", "approve", "enabled"}
|
||||
return False
|
||||
return isinstance(value, str) and value.strip().lower() in _TRUTHY_STRINGS
|
||||
|
||||
|
||||
# --- Pending store (file-backed) ---
|
||||
@@ -75,22 +74,15 @@ def _read_record(path: Path) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def stage_write(subsystem: str, payload: Dict[str, Any], *, summary: str, origin: str) -> Dict[str, Any]:
|
||||
"""Persist a pending write and return its record (``id`` + metadata).
|
||||
|
||||
``payload`` is the exact kwargs to replay the write on approval; ``origin`` is
|
||||
``foreground`` or ``background_review`` (audit). Best-effort: on disk failure it
|
||||
logs and still returns a record — the write is lost, which is the safe failure
|
||||
for an approval gate (nothing silently committed).
|
||||
"""
|
||||
"""Persist a pending write and return its record (``id`` + metadata). ``payload`` is the exact
|
||||
kwargs to replay the write on approval; ``origin`` is ``foreground`` or ``background_review``.
|
||||
Best-effort: on disk failure it logs and still returns a record — the write is lost, which is
|
||||
the safe failure for an approval gate (nothing silently committed)."""
|
||||
pid = uuid.uuid4().hex[:8]
|
||||
record = {
|
||||
"id": pid,
|
||||
"subsystem": subsystem,
|
||||
"action": payload.get("action", ""),
|
||||
"summary": (summary or "").strip(),
|
||||
"origin": origin or "foreground",
|
||||
"created_at": time.time(),
|
||||
"payload": payload,
|
||||
"id": pid, "subsystem": subsystem, "action": payload.get("action", ""),
|
||||
"summary": (summary or "").strip(), "origin": origin or "foreground",
|
||||
"created_at": time.time(), "payload": payload,
|
||||
}
|
||||
try:
|
||||
path = _pending_path(subsystem, pid)
|
||||
@@ -164,12 +156,9 @@ def current_origin() -> str:
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class GateDecision:
|
||||
"""Result of evaluating the write gate. Exactly one flag is True.
|
||||
|
||||
``allow`` proceed with the real write; ``blocked`` the user denied an inline prompt
|
||||
(``message`` explains why); ``stage`` the caller must ``stage_write`` the payload
|
||||
(``message`` is the user-facing "staged for approval" note).
|
||||
"""
|
||||
"""Result of evaluating the write gate; exactly one flag is True. ``allow``: do the real write;
|
||||
``blocked``: user denied the inline prompt (``message`` says why); ``stage``: caller must
|
||||
``stage_write`` the payload (``message`` is the user-facing "staged for approval" note)."""
|
||||
|
||||
allow: bool = False
|
||||
blocked: bool = False
|
||||
@@ -184,13 +173,10 @@ def _staged(subsystem: str) -> GateDecision:
|
||||
|
||||
|
||||
def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: str = "") -> GateDecision:
|
||||
"""Decide what to do with a pending write for ``subsystem``.
|
||||
|
||||
gate off → allow; gate on + skills (any origin) or background → stage; gate on +
|
||||
memory + foreground → inline prompt when an interactive channel exists, else stage.
|
||||
The gate only ever delays a write, never silently refuses it; ``blocked`` is
|
||||
produced only when the user actively denies the inline prompt.
|
||||
"""
|
||||
"""Decide what to do with a pending write: gate off → allow; gate on + skills (any origin) or
|
||||
background → stage; gate on + memory + foreground → inline prompt when an interactive channel
|
||||
exists, else stage. The gate only ever delays a write, never silently refuses it; ``blocked``
|
||||
is produced only when the user actively denies the inline prompt."""
|
||||
if not write_approval_enabled(subsystem):
|
||||
return GateDecision(allow=True)
|
||||
# Skills are too big to review inline; a background write runs in a daemon thread with no user.
|
||||
@@ -205,14 +191,11 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "", inline_detail: st
|
||||
|
||||
|
||||
def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
|
||||
"""Prompt inline for a memory write: True approved, False denied, None → stage.
|
||||
|
||||
Uses the per-thread CLI approval callback (``tools.terminal_tool.set_approval_callback``)
|
||||
directly rather than ``prompt_dangerous_approval``: that wrapper falls back to
|
||||
``input()`` (deadlock-prone under prompt_toolkit; silent deny in gateway sessions)
|
||||
and turns callback errors into a deny, whereas here a missing channel or failed
|
||||
prompt must stage instead.
|
||||
"""
|
||||
"""Prompt inline for a memory write: True approved, False denied, None → stage. Uses the per-thread
|
||||
CLI approval callback (``tools.terminal_tool.set_approval_callback``) directly, not
|
||||
``prompt_dangerous_approval``: that wrapper falls back to ``input()`` (deadlock-prone under
|
||||
prompt_toolkit; silent deny in gateway sessions) and turns callback errors into a deny, whereas
|
||||
here a missing channel or failed prompt must stage instead."""
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user